App Worker
The TanStack Start application: routing, auth, API routes, and the paved-road helpers every endpoint should use.
The app is TanStack Start (React 19, Vite, TanStack Router) deployed to a Cloudflare Worker. Staging is adstack-staging at staging.tryadstack.com; production is adstack at app.tryadstack.com. Both come from the same wrangler.jsonc, switched by CLOUDFLARE_ENV=production.
Routing
File-based routing under src/routes/. Two layout groups plus the API tree:
| Prefix | Purpose |
|---|---|
src/routes/_app/ |
The signed-in product: chat, offers, media library, creative studio, settings |
src/routes/_admin/ |
The admin console: prompts, agents, users, logs, settings, design system |
src/routes/api/ |
Server endpoints |
Route files are thin. The real screens live in src/pages/app/. All routes are SSR: every page needs auth or server data, so there are no static pages.
The index route / is a redirect to /new. There is no marketing content in this codebase, the marketing site is a separate project on the root domain.
Server logic
Two patterns coexist. API routes under src/routes/api/ are the older one, called with manual fetch. New code should prefer createServerFn for type-safe server functions. The migration is gradual, not a sprint.
A custom Vite plugin, serverOnlyModules(), stubs pg, node:crypto, and friends in the client bundle so a stray import chain cannot drag server code into the browser.
Auth
Clerk, since 2026-08-03. Better Auth was removed entirely: the package, lib/auth.ts, the /api/auth/* handler, and the session, account and verification tables.
Clerk answers exactly one question, “who is this”. Every ownership check in the app keys on the internal user.id, which is why the migration never touched authorization. clerk_user_id on the user row is the join.
The app kept its own sign-in, sign-up and forgot-password pages rather than adopting Clerk’s prebuilt components, and drives Clerk through window.Clerk (lib/clerk/client.ts) rather than Clerk’s React hooks. VITE_CLERK_PUBLISHABLE_KEY is a runtime Worker var handed to ClerkProvider by the root loader, so CI needs no build-time secret.
Two things that will bite you:
- Never
await import()a server module inside the auth boundary. A dynamic import oflib/author the Clerk server SDK from a route-imported module built a chunk cycle that 500’d every request on the Worker. Static imports only. - A
__sessioncookie alone does not authenticate an API probe. UseAuthorization: Bearer <jwt>. Clerk’s own API also 403s a default Python user agent, which looks exactly like a dead key.
/api/webhooks/clerk keeps the internal row in sync (create links by Clerk id then by email, delete marks the user banned), and ensureInternalUser self-heals a session whose internal row does not exist yet, so a fresh signup can call authed APIs immediately.
Admin routes sit behind the _admin layout, and sensitive admin actions go through a separate verification step (admin_session and admin_verification tables).
Paved-road route helpers
lib/api/route-helpers.ts exists because two bug classes kept recurring: endpoints that forgot an ownership check, and endpoints that charged credits and never refunded on failure. Import these rather than re-deriving the pattern.
requireUser(request)
Returns { user } or a 401 Response.
const auth = await requireUser(request);
if (auth instanceof Response) return auth;
const { user } = auth;requireOwnedRow(table, id, userId)
Returns the row or null. The caller returns 404, not 403, so the endpoint does not leak whether the id exists.
withCredits({ userId, cost, reason }, fn)
Charges, runs fn, and auto-refunds if it throws. waived: true (bring your own key) or cost: 0 skips the charge. Throws InsufficientCreditsError, which creditErrorResponse(e) maps to a 402.
Wrap only synchronous request-path work. Fire-and-forget background jobs manage their own refund.
submitToPipelineWorker(path, body)
POSTs to the pipeline worker and throws on a non-2xx response, so the caller can mark the row failed and refund. A silent worker submit failure leaves rows stuck “Pending” forever.
Worked examples already refactored onto these: src/routes/api/videos.ts, characters.ts, attachments.ts.
Credits
One credit per AI generation call, one per Firecrawl scrape, zero for storing an offer. Balances live in user_credits with an audit trail in credit_transactions. Checking happens before the expensive call (fast read, blocking); deduction happens after success (non-blocking write). Failures are never charged, which removes the need for a refund path in the common case.
Subscriptions and checkout run through Polar, with webhooks at /api/webhooks/polar. Guest checkout is supported: a purchase creates a pending_customers row and sends a registration email, and credits are granted when the account is created and linked.
Background job awareness
Anything asynchronous gets a background_jobs row. The header bell and the toast cards read from it, polling every 5 seconds while jobs are active and every 30 when idle. The chat host also reads it, so it knows what is still in flight and can say “the persona is still generating” instead of rendering an empty picker.