Modern full-stack React starter. Postgres + better-auth + Drizzle ORM, deployed to Cloudflare Workers.
| Category | Technology |
|---|---|
| Framework | React 19 |
| Routing | TanStack Router |
| Data Fetching | TanStack Query |
| Auth | better-auth |
| Database | Any Postgres (default: Neon) |
| ORM | Drizzle ORM |
| DB Driver | @neondatabase/serverless (HTTP) |
| API Server | Hono |
| Deployment | Cloudflare Workers |
| UI Library | ZUI (CSS-first) |
| Icons | Phosphor Icons |
| Validation | Zod |
| Linting & Formatting | Biome |
| Testing | Vitest |
- Bun (recommended) or Node.js 20+
- A Postgres database — Neon recommended (free tier works)
- A Cloudflare account (for deployment)
bun installSign up at Neon, create a project, and copy the pooled connection string. It looks like:
postgresql://USER:PASSWORD@ep-xxx-pooler.region.aws.neon.tech/DBNAME?sslmode=require
Any Postgres works — Supabase, RDS, Railway, local Docker, etc. Just pass a valid connection string.
Copy .env.example → .env and fill in:
DATABASE_URL=postgresql://... # from step 2
BETTER_AUTH_SECRET= # see below
BETTER_AUTH_URL=http://localhost:3450Generate a secret:
openssl rand -hex 32bun run db:migrateThis applies the SQL files in drizzle/ to your database. Tables created: user, session, account, verification, jwks, profiles.
bun run devApp runs at http://localhost:3450 (proxied as [ras.localhost](http://ras.localhost:1355) via portless).
db/ # Drizzle schema + client
├── schema.ts # Auth tables + profiles
└── client.ts # neon-http drizzle client
drizzle/ # Generated migration SQL
drizzle.config.ts # Drizzle Kit config
src/
├── components/ # AuthProvider, login/signup/password forms, LogoutButton
├── lib/
│ ├── auth/
│ │ ├── server.ts # better-auth server config
│ │ └── client.ts # better-auth React client
│ ├── fetching/
│ │ ├── user.ts # session + profile queries
│ │ └── errorResponse.ts
│ ├── createTitle.ts
│ └── get-error-message.ts
├── routes/ # TanStack Router file-based
│ ├── __root.tsx
│ ├── _authed/ # protected
│ ├── _public/ # login / signup / forgot-password
│ └── index.tsx
├── worker/ # Hono on Cloudflare Workers
│ ├── index.ts # entry
│ ├── hono.ts # routes
│ ├── env.ts # typed env bindings
│ ├── context.ts # request context (session + DB)
│ └── profile.ts # /api/me endpoints
├── types/db.ts
├── constants.ts
├── main.tsx
├── reportWebVitals.ts
└── styles.css # imports @mrmartineau/zui/css
Quick recipes for the most common things you'll want to add. Agents: see AGENTS.md for the same recipes plus project conventions.
Routes are file-based via TanStack Router. Drop a file into src/routes and the route tree (src/routeTree.gen.ts) regenerates automatically while bun run dev is running.
// src/routes/about.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/about")({
component: About,
});
function About() {
return <h1>About</h1>;
}Folder conventions used here:
src/routes/_public/*— unauthenticated pages (login, sign-up, forgot-password). The_publicsegment is a pathless layout route, so files inside resolve at/login,/sign-up, etc.src/routes/_authed/*— pages behind auth._authed/route.tsxrunsgetSession()inbeforeLoadand redirects to/loginwhen there's no session. Anything under_authed/is protected by inheritance.src/routes/_authed/app/*— the app shell. Files here resolve at/app/....
To add a protected page, create src/routes/_authed/app/billing.tsx and it's live at /app/billing with auth already enforced by the parent layout.
Use Link from @tanstack/react-router for SPA navigation. Prefer route constants from src/constants.ts rather than hard-coding paths:
import { Link } from "@tanstack/react-router";
import { ROUTE_APP_HOME } from "@/constants";
<Link to={ROUTE_APP_HOME}>App</Link>;When you add a new top-level route, add a matching ROUTE_* constant so links stay refactor-safe.
src/routes/__root.tsx is the app-wide shell — anything you put there appears on every page, with route content rendered at <Outlet />.
For section-level layouts (e.g. an authed sidebar), edit src/routes/_authed/route.tsx — its JSX wraps every authed child route.
Hono is mounted at /api. Edit src/worker/hono.ts:
import { requireRequestContext } from "./context";
app.get("/widgets", async (c) => {
const ctx = await requireRequestContext(c);
if (ctx instanceof Response) return ctx; // 401
const { db, user } = ctx;
const rows = await db.select().from(widgets).where(eq(widgets.ownerId, user.id));
return c.json(rows);
});requireRequestContext(c) returns either {db, user, profile} or a 401 Response. Use createRequestContext(c) instead if the route is optionally authenticated.
For anything bigger than a couple of handlers, extract to its own file in src/worker/ (see profile.ts for the shape) and import it into hono.ts.
- Edit
db/schema.ts— add the table with Drizzle'spgTable. bun run db:generate— emits a new SQL file indrizzle/.- Commit the SQL.
bun run db:migrate— applies it to whateverDATABASE_URLpoints at.
Re-run bun run cf-typegen only if you change wrangler.jsonc bindings — schema changes don't need it.
The QueryClient is already provided in src/main.tsx. Use useQuery directly in a component, or call it from inside a route loader to prefetch.
import { useQuery } from "@tanstack/react-query";
function Widgets() {
const { data } = useQuery({
queryKey: ["widgets"],
queryFn: () => fetch("/api/widgets").then((r) => r.json()),
});
return <ul>{data?.map((w) => <li key={w.id}>{w.name}</li>)}</ul>;
}For session-aware data, see src/lib/fetching/user.ts for the existing getSession() / profile patterns.
Plain React components live in src/components/. UI primitives come from ZUI — use zui-button, zui-card, zui-input classes on regular HTML elements rather than wrapping anything. Tokens (--space-*, --color-*, --step-*) are CSS custom properties, available in any stylesheet.
- Add to
.envfor local dev. - Add to
wrangler.jsoncvars(non-secret) orbunx wrangler secret put NAME(secret). - Add to
WorkerEnvinsrc/worker/env.tsso it's typed inside Hono handlers. - Run
bun run cf-typegento refreshworker-configuration.d.ts.
Email/password via better-auth:
- Sign up —
authClient.signUp.email({email, password, name}) - Sign in —
authClient.signIn.email({email, password}) - Sign out —
authClient.signOut() - Session —
authClient.useSession()(live React hook) - Forgot password — POST
/api/auth/forget-password(server needssendResetPasswordconfigured to actually send mail; seesrc/lib/auth/server.ts)
A profiles row is auto-inserted via databaseHooks.user.create.after on sign up.
src/routes/_authed/route.tsx calls getSession() in beforeLoad and redirects to /login if missing.
src/worker/context.ts exposes:
createRequestContext(c)— pulls session from request headers, returns{db, user, profile}requireRequestContext(c)— same, returns 401 Response if no auth
Supports both cookie sessions and Authorization: Bearer <api_key> (where api_key is the UUID stored in profiles.api_key).
Hono mounted at /api. Defined in src/worker/hono.ts:
app.on(['GET', 'POST'], '/auth/*', ...) // better-auth handler
app.get('/me', getCurrentProfile)
app.patch('/me', updateCurrentProfile)Add new routes in hono.ts. Use requireRequestContext(c) inside handlers needing auth.
Drizzle ORM with Neon HTTP driver — designed for Workers' edge runtime. Full guide in docs/DATABASE.md: conventions, safe vs. destructive migrations, hand-editing SQL, switching providers, prod migrations.
- Edit
db/schema.ts bun run db:generate→ emits SQL indrizzle/- Inspect the SQL — stop if it drops or renames anything you didn't intend
- Commit
db/schema.ts+ the SQL +drizzle/meta/together bun run db:migrate→ applies to your DB
import { eq } from "drizzle-orm";
import { createDb } from "../../db/client";
import { profiles } from "../../db/schema";
const db = createDb(env);
const [profile] = await db.select().from(profiles).where(eq(profiles.id, userId));The @neondatabase/serverless driver speaks Postgres wire protocol over HTTP. To switch off Neon:
- Self-hosted / RDS / Supabase: swap
db/client.tstodrizzle-orm/node-postgres+pg.Pool(requiresnodejs_compatflag — already set inwrangler.jsonc). - Cloudflare Hyperdrive: bind a Hyperdrive in
wrangler.jsonc, read itsconnectionString.
bunx wrangler secret put DATABASE_URL
bunx wrangler secret put BETTER_AUTH_SECRETUpdate BETTER_AUTH_URL in wrangler.jsonc vars to your production URL.
bun run deployAuthenticate first if needed: bunx wrangler login.
wrangler.jsonc:
nodejs_compatflag — required for bcryptjs + better-auth internalsassets.not_found_handling: "single-page-application"— SPA fallbackplacement.mode: "smart"— Worker runs near your DBai.binding: "AI"— Workers AI available asenv.AI
| Script | What it does |
|---|---|
bun run dev |
Start dev server on port 3450 |
bun run build |
Vite build + tsc |
bun run preview |
Preview production build |
bun run deploy |
Build + wrangler deploy |
bun run db:generate |
Generate Drizzle migrations from schema |
bun run db:migrate |
Apply migrations to DATABASE_URL |
bun run db:studio |
Open Drizzle Studio |
bun run test |
Run Vitest |
bun run type-check |
TypeScript check |
bun run cf-typegen |
Regenerate worker-configuration.d.ts |
bun run check |
Biome check + autofix |
bun run format |
Biome format |
ZUI is a CSS-first component + token library. Imported once in src/styles.css:
@import "@mrmartineau/zui/css";Components are plain elements with zui-* classes (zui-button, zui-card, zui-input, etc.) — no JS wrappers, no build step. Design tokens (--space-*, --color-*, --step-*) are exposed as CSS custom properties.
| Env var | Purpose |
|---|---|
DATABASE_URL |
Postgres connection string |
BETTER_AUTH_SECRET |
32+ char random hex; signs sessions |
BETTER_AUTH_URL |
Public origin (cookies + redirect URLs) |
BETTER_AUTH_TRUSTED_ORIGINS |
Comma-separated. Defaults to BETTER_AUTH_URL |
BETTER_AUTH_DISABLE_SIGNUP |
true to lock out new sign-ups |
MIT