20 KiB
Code-Review: webkulisse-saas (TanStack Start)
Stack: TanStack Start + Router (file-based), React 19 + Compiler, better-auth 1.5 (admin-Plugin), Drizzle + Postgres, Tailwind 4, shadcn (radix-vega), Nitro, Biome
Stand: Landingpage + /termin + Auth + Admin-Userverwaltung + Dashboard-Platzhalter
Gesamteindruck: Die Grundstruktur ist gut. Route-Groups (_authenticated, _admin) sind richtig gewählt, Guards sitzen am Layout statt an jeder Seite, better-auth macht die Schwerarbeit statt selbstgebautem Auth, und die Kommentare in config.ts / booking-dialog.tsx zeigen, dass du über Entscheidungen nachdenkst. Das Lazy-Loading des cal.com-Bundles ist ein sauberer Move.
Die Probleme liegen weniger im Auth-Code selbst als in drei Bereichen: Deploy-Reife (Devtools, Env-Vars, Rechtstexte), Datenfetching-Muster (das ist dein Spaghetti-Risiko für Rechnungen/Abnahmen), und Frontend-Details, die dem eigenen Marketing-Versprechen widersprechen.
KRITISCH — vor dem Livegang
K1. Impressum und Datenschutzerklärung sind tote Links
src/components/footer.tsx:111,116 — beide href="#".
§5 DDG (ehem. TMG) Impressumspflicht und DSGVO Art. 13 sind bei einer gewerblichen Seite nicht optional. Abmahnrisiko besteht real. Zusätzlich brisant: Du verkaufst im Klassik-Paket „Rechtstexte-Assistenz" — eine Agenturseite ohne eigenes Impressum untergräbt genau dieses Verkaufsargument.
Die Datenschutzerklärung braucht außerdem einen Absatz zum cal.com-Embed (termin.hurler-webdesign.de). Dass du selbst hostest, ist gut — das erspart dir den US-Drittlandtransfer, aber die iframe-Einbindung und die dort erhobenen Daten gehören trotzdem beschrieben.
Fix: Zwei echte Routen /impressum und /datenschutz anlegen, im Footer verlinken, beide mit robots: noindex ist nicht nötig — die sollen indexiert werden.
K2. TanStack Devtools laufen in Produktion mit
src/routes/__root.tsx:94-104 — <TanStackDevtools> wird unbedingt gerendert. Zusätzlich stehen @tanstack/react-devtools, @tanstack/react-router-devtools und drizzle-kit in dependencies statt devDependencies (package.json).
Folge: Jeder Besucher lädt das Devtools-Bundle, und der komplette Route-Tree inklusive aller Admin-Pfade ist im Browser inspizierbar. Kein direktes Auth-Leck, aber unnötige Angriffsfläche und deutlich mehr JS auf einer Seite, die mit „unter 1 Sekunde Ladezeit" wirbt.
Fix:
{import.meta.env.DEV && <TanStackDevtools ... />}
Plus die drei Pakete nach devDependencies verschieben.
K3. Keine Fail-Fast-Prüfung der Environment-Variablen
src/lib/auth.ts:11-15, src/db/index.ts:5
baseURL: process.env.BETTER_AUTH_URL, // undefined möglich
secret: process.env.BETTER_AUTH_SECRET, // undefined möglich
trustedOrigins: process.env.BETTER_AUTH_URL ? [...] : undefined,
db = drizzle(process.env.DATABASE_URL!, ...) // Non-Null-Assertion kaschiert den Fehler
Der trustedOrigins-Fallback ist der gefährlichste Teil: Fehlt BETTER_AUTH_URL im Deployment, fällt der Origin-Check weg bzw. leitet sich aus Request-Headern ab. Das ist die Basis des CSRF-Schutzes bei better-auth. Ein vergessenes Env-Var in Coolify und die App startet klaglos mit deaktiviertem Schutz.
Fix: Ein src/lib/env.ts, das beim Boot validiert und bei fehlenden Werten wirft — kein !, kein stiller Fallback:
function required(name: string): string {
const v = process.env[name]
if (!v) throw new Error(`Fehlende Umgebungsvariable: ${name}`)
return v
}
export const env = {
DATABASE_URL: required('DATABASE_URL'),
BETTER_AUTH_URL: required('BETTER_AUTH_URL'),
BETTER_AUTH_SECRET: required('BETTER_AUTH_SECRET'),
}
Dazu eine .env.example ins Repo (fehlt aktuell komplett).
HOCH
H1. Passwort-Reset beendet keine Sessions — die UI behauptet das Gegenteil
src/routes/_admin/admin.users.$userId.tsx:246-256, 270-273
Der Text sagt: „Überschreibt das aktuelle Passwort sofort. Der Kunde muss sich neu anmelden." — authClient.admin.setUserPassword() invalidiert bestehende Sessions aber nicht. Wer schon eingeloggt ist, bleibt es.
Das ist genau der Fall, der zählt: Kunde meldet „mein Zugang wurde kompromittiert", du setzt ein neues Passwort, und die Session des Angreifers läuft weiter (bis zu 7 Tage, expiresIn).
Fix: revokeUserSessions() direkt nach setUserPassword() mitlaufen lassen — oder die UI-Aussage korrigieren. Ersteres.
H2. Guards nur in beforeLoad — heute ok, ab Rechnungen nicht mehr
src/routes/_authenticated.tsx, src/routes/_admin.tsx
Aktuell sicher, weil alle schreibenden Aktionen über das better-auth-Admin-Plugin laufen, das serverseitig selbst prüft. Die beforeLoad-Guards sind reines UX-Routing.
Das kippt in dem Moment, wo du deine erste eigene Server-Function für Rechnungen schreibst. beforeLoad läuft bei Client-Navigation im Browser — es ist keine Autorisierung. Wenn getInvoicesFn() sich darauf verlässt, dass der Nutzer „ja über _authenticated gekommen ist", kann jeder die Server-Function direkt aufrufen.
Fix jetzt, nicht später — das ist die wichtigste Architekturentscheidung im ganzen Review. Leg eine Middleware an, bevor die erste Rechnungs-Function existiert:
// src/lib/middleware.ts
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { auth } from '#/lib/auth.ts'
export const authMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
const session = await auth.api.getSession({ headers: getRequest().headers })
if (!session) throw new Error('UNAUTHORIZED')
return next({ context: { session } })
})
export const adminMiddleware = createMiddleware({ type: 'function' })
.middleware([authMiddleware])
.server(async ({ next, context }) => {
if (context.session.user.role !== 'admin') throw new Error('FORBIDDEN')
return next({ context })
})
Regel fürs Team-of-one: Jede createServerFn bekommt eine dieser beiden Middlewares. Keine Ausnahme. Und jede Rechnungsabfrage filtert zusätzlich auf context.session.user.id — nicht auf eine ID aus den Function-Parametern, sonst hast du direkt ein IDOR.
H3. Kein Passwort-Reset für Kunden, Klartext-Initialpasswörter
src/routes/_admin/admin.users.new.tsx:89-100, src/lib/auth.ts:25-30
- Initialpasswort als
type="text"— steht im Klartext auf dem Bildschirm. Bewusste Entscheidung (du musst es ja weitergeben), aber es landet auch im Browser-Autofill und ggf. in Screenshots. - Kein Zwang, es nach dem ersten Login zu ändern. Es gilt unbegrenzt.
- Kein
sendResetPasswordkonfiguriert → Kunde, der sein Passwort vergisst, muss dich anrufen. - Gleichzeitig existieren Rate-Limit-Regeln für
/forget-passwordund/reset-password(auth.ts:47-48) — toter Code für Endpunkte, die nicht funktionieren.
Fix, in der Reihenfolge: Kurzfristig einen Passwortgenerator statt Freitextfeld (16 Zeichen, Copy-Button). Mittelfristig sendResetPassword mit Mailer konfigurieren und den Einladungs-Flow auf Setup-Link statt Klartext-Passwort umstellen. Dann wird auch das Rate-Limiting oben wieder sinnvoll.
H4. Rate-Limiting im Speicher
src/lib/auth.ts:43 — storage: 'memory'
Setzt sich bei jedem Deploy zurück und wirkt pro Prozess. Bei einem Container auf Coolify ist das mager, aber tolerabel; sobald du skalierst oder häufig deployst, ist der Brute-Force-Schutz (max: 5 auf /sign-in/email) faktisch offen.
Fix: storage: 'database' — die Tabelle hast du eh schon, und du brauchst kein Redis dafür.
H5. redirect-Search-Param wird gesetzt, aber nie ausgewertet
src/routes/_authenticated.tsx:8, src/routes/_admin.tsx:8 schreiben search: { redirect: location.href }. src/routes/login.tsx liest ihn nie und navigiert immer nach /dashboard bzw. /admin.
Zwei Probleme: Deep-Links nach dem Login sind kaputt (Kunde klickt Link zu einer Rechnung, loggt sich ein, landet auf dem Dashboard). Und /login hat kein validateSearch — der Param ist untypisiert.
Wenn du ihn später auswertest, unbedingt validieren, sonst hast du einen Open Redirect:
validateSearch: (search) => ({
redirect: typeof search.redirect === 'string' && search.redirect.startsWith('/')
? search.redirect
: undefined,
})
Nur relative Pfade. //evil.com fängt startsWith('/') nicht ab — also zusätzlich !redirect.startsWith('//').
H6. Login und Dashboard sind für Suchmaschinen freigegeben
src/routes/__root.tsx:43 setzt global robots: index,follow, kein Override in login.tsx, dashboard.tsx oder den Admin-Routen.
Der Kundenlogin landet im Google-Index. Zusätzlich erbt jede Route den harten Canonical https://webkulisse.de/ (__root.tsx:79) — /login zeigt kanonisch auf die Startseite, was SEO-technisch Unsinn ist.
Fix: In allen Routen unter _authenticated / _admin sowie auf /login ein head: () => ({ meta: [{ name: 'robots', content: 'noindex,nofollow' }] }). Canonical aus dem Root entfernen und pro Route setzen — termin.tsx macht das schon richtig, das ist das Muster.
MITTEL — Code-Qualität, hier entsteht der Spaghetti-Code
M1. Datenfetching per useEffect statt Router-Loader
admin.index.tsx:25-39, admin.users.$userId.tsx:35-61
Beide Seiten haben handgeschriebenes useState für Daten, Loading und Error plus useEffect zum Laden. Das ist bei zwei Seiten überschaubar. Bei Rechnungen, Abnahmen, Dokumenten und Nachrichten sind es acht Seiten mit acht Kopien derselben fünf Zeilen — und genau das wolltest du vermeiden.
TanStack Router hat dafür Loader. Damit bekommst du Prefetching bei Hover (defaultPreload: 'intent' hast du schon aktiv), automatische Pending-States, router.invalidate() statt manuellem load(), und die Daten sind typisiert über Route.useLoaderData().
export const Route = createFileRoute('/_admin/admin/')({
loader: () => listUsersFn(),
component: AdminUsersPage,
pendingComponent: () => <p>Lade Kunden …</p>,
errorComponent: ({ error }) => <ErrorBox error={error} />,
})
Für Mutationen mit Server-State (Rechnungsstatus, Abnahmen) würde ich zusätzlich TanStack Query dazunehmen — steht noch nicht im package.json, passt aber nahtlos zum Router.
M2. Race Condition beim Laden des Users
admin.users.$userId.tsx:59-61 — useEffect(() => { void load() }, [userId]) ohne Abbruch. Wechselt userId schnell, kann die ältere Response die neuere überschreiben. Löst sich mit M1 automatisch auf.
M3. Fehler beim Sperren werden verschluckt
admin.index.tsx:41-50 — toggleBan() ignoriert das Ergebnis von banUser/unbanUser komplett. Schlägt der Call fehl, sieht der Admin nichts; erst load() zeigt, dass sich nichts geändert hat. Bei einer Sicherheitsfunktion („Kunde sperren") ist stiller Fehlschlag die falsche Wahl.
M4. Zwei Quellen der Wahrheit für die Session
src/components/header.tsx:20 nutzt authClient.useSession() (Client-Fetch), während der Router die Session bereits über __root.tsx:25-28 im Context hat.
Folge: zusätzlicher Request auf der Landingpage für jeden anonymen Besucher, plus ein Flackern des Buttons von „Kundenlogin" zu „Zum Dashboard" nach der Hydration.
Fix: useRouteContext({ from: '__root__' }) und die vorhandene Session nutzen.
M5. Zwei Import-Aliase parallel
#/ (29 Stellen, definiert in package.json imports) und @/ (14 Stellen, definiert in tsconfig.json paths). Teils in derselben Datei-Ebene: login.tsx nutzt #/, termin.tsx nutzt @/. components.json ist auf #/ konfiguriert, neue shadcn-Komponenten kommen also mit #/.
Fix: Auf #/ vereinheitlichen (das ist der Node-Standard und passt zu shadcn), @/* aus der tsconfig entfernen, dann fängt TypeScript Rückfälle ab.
M6. Schema-Drift: todos-Tabelle
drizzle/0000_majestic_hammerhead.sql:38-42 legt eine todos-Tabelle an, die in src/db/schema.ts nicht existiert. Überbleibsel aus dem CTA-Template. Ein db:generate erzeugt jetzt eine Drop-Migration — oder du hast eine Waisen-Tabelle in Produktion.
Nebenbei: todos.json steht im .gitignore, gehört zum selben Rest.
M7. Seed-Script ist gitignored
.gitignore enthält scripts, package.json referenziert tsx scripts/seed-admin.ts als db:seed. Auf einem frischen Clone existiert das Script nicht — du kannst keinen initialen Admin anlegen, und es gibt keinen anderen Weg (disableSignUp: true).
Fix: scripts aus .gitignore raus (falls das Secrets enthält: Secrets über Env-Vars, nicht über gitignorierte Scripts).
M8. Keine Error- und NotFound-Boundaries
Nirgends errorComponent, notFoundComponent oder defaultNotFoundComponent. Ein Fehler in einem Loader zeigt aktuell den nackten TanStack-Default. Mindestens im Root definieren.
M9. account.issuer ist NOT NULL
schema.ts:35. Bitte prüfen, ob better-auth 1.5 dieses Feld bei reinen Credential-Accounts (E-Mail/Passwort) immer befüllt — falls nicht, schlägt createUser fehl. Da du aktuell keine OAuth-Provider hast, ist das der einzige Pfad, der Accounts anlegt. Falls es funktioniert: gut, dann nur zur Kenntnis. Falls nicht: .notNull() entfernen.
M10. Fehlende Indizes
session.user_id und account.user_id haben Foreign Keys, aber keine Indizes. Bei jedem revokeUserSessions und jedem Login-Lookup ein Full Scan. Aktuell bei einer Handvoll Kunden irrelevant, aber ein Zweizeiler:
index('session_user_id_idx').on(table.userId)
DESIGN & FRONTEND
D1. Deine Hausschrift lädt nicht
src/styles.css:9-14:
src: url('/public/fonts/VendSans-VariableFont_wght.woff2');
Der public/-Ordner ist der Web-Root. Der korrekte Pfad ist /fonts/VendSans-VariableFont_wght.woff2. Aktuell: 404, --font-sans fällt auf System-Sans zurück. Die gesamte Seite rendert also in einer anderen Schrift als du designt hast.
Dazu fehlt font-display: swap — ohne das gibt es einen unsichtbaren Text-Blitz beim Laden.
D2. Ungenutzte Schriften und Bilder — 6 MB im public/
nunito-sanswird instyles.css:5importiert, aber nirgends verwendet (--font-headingnutzt Public Sans).AlegreyaSC-Bold.woff2(104 KB),SourceCodePro(88 KB),SourceSerif4(416 KB) liegen ungenutzt impublic/fonts/.- Vier von fünf Bildern sind ungenutzt:
desk-light.webp(2,0 MB),wood-desk-clean-dark.webp(1,3 MB),desk-red-orange.webp(792 KB),desk-clean-dark.webp(488 KB).
Nitro kopiert public/ unverändert ins Deployment. Du schleppst also ~5 MB toten Ballast mit.
D3. Placeholder-Bilder in Produktion, während echte Assets ungenutzt danebenliegen
hero.tsx:58 und footer.tsx:50 laden von https://placehold.co. Das ist im Hero-Bereich — das Erste, was ein Besucher sieht — und obendrein eine externe Abhängigkeit, die dir die Ladezeit versaut und datenschutzrechtlich in die Erklärung müsste.
Gleichzeitig hast du fünf passende Schreibtisch-Fotos im Projekt liegen, die keiner nutzt. Das wirkt wie ein reiner Vergessens-Fehler.
D4. Bilder ohne responsive Auslieferung
features.tsx lädt laptop-planning.webp (684 KB) mit width="5184" height="3456", angezeigt wird es maximal 1152 px breit. Kein srcset, kein sizes. Auf dem Smartphone lädt jemand ein 5K-Bild für eine 400px-Anzeige.
Außerdem: logo.png ist 228 KB für eine 40×40-Darstellung — und dient gleichzeitig als Favicon und als Open-Graph-Bild.
Das alles steht direkt gegen dein zentrales Verkaufsversprechen („Unter 1 Sekunde Ladezeit", hero.tsx:6). Die eigene Seite ist das Portfolio-Stück — hier sollte ein Lighthouse-Run bei 100 landen.
D5. Header bricht außerhalb der Startseite
src/components/header.tsx:
- Zeile 53: Logo ist
<a href="#">statt<Link to="/">. Auf/terminführt ein Klick aufs Logo nirgendwohin. - Zeilen 9-14: Alle Nav-Links sind Anker (
#zielgruppen,#pricing…). Auf/terminexistieren diese Anker nicht — die komplette Hauptnavigation ist dort tot.
Fix: <Link to="/" hash="pricing"> statt roher Anker, dann funktioniert es von jeder Route aus.
D6. Open Graph unbrauchbar
__root.tsx:60,69 — og:image ist /img/logo.png, ein relativer Pfad. Open Graph verlangt absolute URLs; Facebook, LinkedIn und WhatsApp zeigen also gar kein Bild. Zusätzlich ist ein Logo im falschen Format — 1200×630 wäre richtig.
D7. Kleinigkeiten
styles.css:2-3:tw-animate-csswird zweimal importiert (einmal mit einfachen, einmal mit doppelten Anführungszeichen).__root.tsx:13: Variable heißtthemeItnitScript(Tippfehler „Itnit").header.tsx:100:aria-label="Menü öffnen"bleibt statisch, auch wenn das Menü offen ist. Screenreader-Nutzer bekommen die falsche Ansage..rise-in(styles.css:336) hat keinenprefers-reduced-motion-Fallback.- Kein
robots.txt, keinesitemap.xml. admin.index.tsx:82: Tabelle ohne<caption>und ohnescope="col"an den Headern.admin.users.$userId.tsx:429: nativesconfirm()fürs Löschen. Funktioniert, passt aber stilistisch nicht zum Rest — du hast bereits einen Dialog incomponents/ui/dialog.tsx.
PLAUSIBILITÄT / INHALT
P1. Platzhalter-Telefonnummer
footer.tsx:32,92 — +49 176 22222222. Steht zweimal auf der Seite als Kontaktweg.
P2. Uneinheitliche Marke
Die Seite heißt „webkulisse", die Domain ist webkulisse.de, die Anbieterangabe im Footer lautet „Mathias Hurler Webdesign", und der Buchungskalender liegt auf termin.hurler-webdesign.de. Der Besucher sieht beim Klick auf „Erstgespräch buchen" also plötzlich eine fremde Domain. Rechtlich in Ordnung (Anbieterangabe muss der echte Name sein), aber für das Vertrauen im Buchungsmoment ungünstig. Ein CNAME auf termin.webkulisse.de löst das.
P3. Werbeaussagen gegen den eigenen Code prüfen
features.tsx:11-13: „Kein Content-Management-System, keine Plugins, keine Datenbank." — Das gilt fürs Kompakt-Paket, nicht für „Ihre neue Webapp" (das hat laut pricing.tsx:54-56 Login und Kundenportal, also zwangsläufig eine Datenbank). Deine eigene Seite ist das beste Gegenbeispiel. Ich würde die Formulierung an den Paket-Kontext binden, sonst widerspricht sich die Seite selbst.
Ebenso pricing.tsx:24: Das Kompakt-Paket verspricht ein Kontaktformular. Bei einer Seite „ohne Datenbank" braucht das einen Mail-Service — nur zur Sicherheit, dass der Aufwand eingepreist ist.
P4. Showcase ist leer
showcase.tsx zeigt drei „In Kürze"-Kacheln. Auf einer Agenturseite ist der Referenz-Bereich das stärkste Verkaufsargument; leer wirkt er schwächer als gar nicht vorhanden. Solange nichts da ist, würde ich den Abschnitt ausblenden statt Platzhalter zu zeigen.
Empfohlene Reihenfolge
Vor dem Livegang: K1 (Rechtstexte), K2 (Devtools), K3 (Env-Validierung), H6 (noindex), D1 (Font-Pfad), D3 (Placeholder-Bilder), P1 (Telefonnummer)
Direkt danach: H1 (Session-Revoke), H4 (Rate-Limit in DB), H5 (Redirect), M6/M7 (Schema-Drift, Seed-Script), D2/D4 (Assets)
Bevor die erste Rechnungs-Funktion entsteht: H2 (Auth-Middleware), M1 (Loader-Muster), M5 (Alias vereinheitlichen), M8 (Error-Boundaries)
H2 und M1 sind die beiden, die über Spaghetti-Code entscheiden. Alles andere ist reparierbar; ein Datenzugriffs-Muster, das sich über zwanzig Dateien verteilt hat, nicht mehr ohne größeren Umbau.
Wenn du das an Junie gibst
Der Teil, den Junie am besten übernehmen kann, ist der mechanische: M5 (Alias-Vereinheitlichung über alle Dateien), M1 (Umbau auf Loader), D7 (Kleinigkeiten), M3/M2 (Fehlerbehandlung). Bei H2 würde ich die Middleware selbst schreiben und Junie nur die Anwendung auf neue Server-Functions überlassen — das ist die Stelle, an der du verstehen willst, was passiert.