Skip to content
 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

108 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TanStack Start on Cloudflare

AI agent index: llms.txt

A production-ready template for building full-stack React apps on Cloudflare Workers. Ships with TanStack Start (SSR + file-based routing), a Hono API layer, Neon Postgres via Drizzle ORM, Zod validation, Shadcn/UI, and a strict Biome + Vitest toolchain.

Use it as the starting point for your next project — clone it, rename it, wire up your database, and start shipping.

TanStack Start on Cloudflare

Using this Template

  1. Click Use this template on GitHub (or gh repo create --template).
  2. pnpm install.
  3. pnpm run init-project — prompts for a kebab-case project name, renames wrangler.jsonc + package.json, and fans out .env.example.env and .dev.vars.example.dev.vars / .staging.vars / .production.vars. Idempotent — re-runnable, never overwrites filled-in files. The script's "Next steps" output lists every field that still needs a value.
  4. Provision a Neon database and fill DATABASE_HOST/USERNAME/PASSWORD in .dev.vars (and the staging / production variants when you deploy them).
  5. Run pnpm cf-typegen && pnpm db:migrate:dev && pnpm dev.
  6. (Optional, when you're done with the demo) delete src/db/client/ and src/hono/api/clients.ts. Then start modelling your own domain.

See Quick Start below for the dev-loop commands.

Before you deploy anything: the demo API is public unauthenticated CRUD. Read Security posture.

Remove these on project start

The following files exist purely to demonstrate the server-function + middleware wiring. They are not imported by any production route — delete them as soon as you start modelling your own domain so they don't linger in the import graph or your search results:

  • src/core/middleware/example-middleware.ts
  • src/core/functions/example-functions.ts
  • src/components/demo/middleware-demo.tsx
  • src/components/demo/index.ts

After deleting, also drop the matching src/readme-demo-cleanup.test.ts (it exists to keep this list honest) and run pnpm knip to catch any stragglers.

Why this template

  • Edge-first — single src/server.ts entrypoint that routes /api/* to Hono and everything else to TanStack Start, all running on Cloudflare Workers.
  • Type-safe end-to-end — strict TypeScript, Zod at every boundary, Drizzle-inferred DB types, typed Cloudflare Env via wrangler types.
  • Deep modules — domain-oriented layout (src/db/{domain}/, src/hono/api/{name}.ts) with narrow public APIs. See .claude/rules/deep-modules.md.
  • Batteries included — error infrastructure, Neon + Drizzle migrations, Shadcn/UI, TanStack Query SSR hydration, Vitest, Biome, knip, semantic-release, taze.
  • Agent-friendly — project rules in .claude/rules/ activate automatically based on the files you touch.

Quick Start

# Install dependencies
pnpm install

# Copy env template and fill in your Neon credentials
cp .dev.vars.example .dev.vars

# Generate Cloudflare Env types
pnpm cf-typegen

# Run migrations against your dev database
pnpm db:migrate:dev

# Start the dev server
pnpm dev

The app runs on http://localhost:3000. API endpoints are served under /api/*.

Scripts

Script Purpose
pnpm dev Dev server on port 3000 (Vite + Cloudflare plugin)
pnpm build Production build
pnpm serve Preview the production build locally
pnpm build:{staging,production} Build for a specific env (bakes env config via vite build --mode <env>)
pnpm run deploy Build and deploy to the default (dev) Cloudflare Workers config — needs pnpm run, as bare deploy is pnpm's own workspace command
pnpm deploy:staging Build with --mode staging and deploy the pre-configured worker
pnpm deploy:production Build with --mode production and deploy the pre-configured worker
pnpm cf-typegen Generate Env types from wrangler.jsonc
pnpm test / pnpm test:watch / pnpm test:coverage Vitest
pnpm types tsc --noEmit type-check
pnpm lint / pnpm lint:fix Biome check / auto-fix
pnpm knip Detect unused files, deps, and exports
pnpm db:generate:{dev,staging,production} Generate Drizzle migrations for each env
pnpm db:migrate:{dev,staging,production} Apply migrations to each env
pnpm db:pull:{dev,staging,production} Pull schema from existing DB
pnpm db:studio Open Drizzle Studio against dev
pnpm db:seed:{dev,staging,production} Run scripts/seed.ts against each env
pnpm deps / pnpm deps:update Check / apply dependency updates via taze
pnpm release semantic-release

All db:* scripts load secrets via @dotenvx/dotenvx from .dev.vars, .staging.vars, or .production.vars.

Knowing the deploy scripts is not the same as knowing the order to run them in. Releasing is a manual procedure by decision, and the Release & rollback runbook is that procedure: migrate, build, deploy and verify per environment, plus how to ship to a fraction of traffic first and how to get back to a working version.

Project Structure

src/
├── server.ts                  # CF Workers entry — routes /api/* → Hono, rest → TanStack Start
├── router.tsx                 # TanStack Router instance
├── routes/                    # File-based routes (auto-generates routeTree.gen.ts)
│   ├── __root.tsx
│   ├── index.tsx
│   └── clients.tsx
├── components/
│   ├── ui/                    # Shadcn primitives (do not edit manually)
│   ├── landing/               # Landing page sections
│   ├── navigation/            # App navigation
│   ├── theme/                 # Theme provider / toggle
│   └── clients/               # Feature components
├── core/
│   ├── errors.ts              # AppError, Result<T>, isUniqueViolation
│   ├── functions/             # TanStack server functions
│   └── middleware/            # Server-function middleware
├── db/
│   ├── setup.ts               # initDatabase / getDb singleton
│   ├── index.ts               # Public DB module API
│   ├── schema.ts              # Re-exports all tables
│   ├── migrations/dev/        # Drizzle migrations (staging/production on demand)
│   ├── client/                # Domain: clients (table, queries, zod schema)
│   └── health/                # Domain: health check query
├── hono/
│   ├── factory.ts             # Typed Hono factory with CF Bindings
│   ├── api.ts                 # Router mounting /api/health, /api/clients
│   └── api/
│       ├── health.ts
│       └── clients.ts         # REST CRUD for clients
├── integrations/tanstack-query/
├── lib/
├── utils/
└── styles.css                 # Tailwind v4 entry

Path alias @/* resolves to src/*.

Tech Stack

Layer Technology
Framework TanStack Start (Router + Query SSR)
UI React 19, Shadcn/UI (new-york, Zinc), Tailwind CSS v4, Lucide
API Hono on Cloudflare Workers
Runtime Cloudflare Workers (nodejs_compat)
Database Neon Postgres + Drizzle ORM (neon-http)
Validation Zod 4
Forms TanStack Form
Language TypeScript (strict)
Linter Biome 2
Testing Vitest + Testing Library + jsdom
Dead-code detection knip
Release semantic-release
Package manager pnpm 10

Cloudflare Integration

wrangler.jsonc

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "tanstack-start-app",
  "compatibility_date": "2026-05-25",
  "compatibility_flags": ["nodejs_compat"],
  "main": "./src/server.ts",
  "vars": {
    "CLOUDFLARE_ENV": "dev"
  },
  "secrets": {
    "required": ["DATABASE_HOST", "DATABASE_USERNAME", "DATABASE_PASSWORD"]
  },
  "upload_source_maps": true,
  "workers_dev": true,
  "preview_urls": true
}

DATABASE_HOST, DATABASE_USERNAME, and DATABASE_PASSWORD are secrets, not plain vars — they reach the Worker via .dev.vars locally and wrangler secret put in deployed environments (see Secrets & Environments). The secrets.required block is the single source of truth for their names: wrangler types emits them on Env from that declaration rather than inferring them from .dev.vars, so type generation produces identical output on a fresh checkout with no secrets file. It also constrains which keys local dev loads, and makes wrangler deploy fail with a named list when one was never set on the Worker.

secrets is not inherited by env blocks — repeat the same block inside env.staging and env.production.

  • Use wrangler.jsonc (not .toml) for configuration.
  • Prefer custom_domain: true over routes with zone_name — see .claude/rules/cloudflare-deployment.md.
  • Run pnpm cf-typegen whenever you add bindings to regenerate worker-configuration.d.ts.

upload_source_maps is on, and inherited by every environment. Observability is already enabled above; without source maps the traces it collects point at minified output, which is the expensive half of the feature paying for the useless half.

Reachability per environment

Stated in the configuration rather than left to platform defaults, so you know where an environment answers before you deploy it — not after.

Environment workers.dev URL Preview URLs Custom domain
dev (top level) on on
staging on on commented placeholder
production off off commented placeholder

Production is deliberately unreachable until you fill in its custom domain. The demo API is public unauthenticated CRUD (see Security posture) and a workers.dev URL is guessable, so the template will not put that combination on the internet for you. Uncomment the routes line in env.production, or set workers_dev back to true if you actually want the subdomain.

Smart Placement

Present in wrangler.jsonc, commented out. It moves your Worker's execution towards your database instead of towards your users, which pays off only for a specific shape of application. The decision record covers when to enable it and how to measure whether it helped — the config points there rather than repeating it.

Custom Server Entry (src/server.ts)

One fetch handler owns the entire worker: it boots the DB once per isolate, then dispatches to Hono or TanStack Start.

import handler from "@tanstack/react-start/server-entry";
import { initDatabase } from "@/db";
import { apiHono } from "@/hono/api";

export default {
  fetch(request: Request, env: Env, ctx: ExecutionContext) {
    initDatabase({
      host: env.DATABASE_HOST,
      username: env.DATABASE_USERNAME,
      password: env.DATABASE_PASSWORD,
    });

    const url = new URL(request.url);

    if (url.pathname.startsWith("/api/")) {
      return apiHono.fetch(request, env, ctx);
    }

    return handler.fetch(request, { context: { fromFetch: true } });
  },
};

You can extend this handler with Queue consumers, scheduled events, or Durable Object bindings as your project grows.

Secrets & Environments

Secrets live in per-environment .vars files, never committed:

# .dev.vars
CLOUDFLARE_ENV=dev
DATABASE_HOST="ep-xxx.region.aws.neon.tech/neondb?sslmode=require"
DATABASE_USERNAME="neondb_owner"
DATABASE_PASSWORD="npg_xxx"

For staging/production, create .staging.vars / .production.vars for local DB tooling (Drizzle migrations, etc.), and push the same keys to Cloudflare as secrets:

wrangler secret put DATABASE_HOST       --env staging
wrangler secret put DATABASE_USERNAME   --env staging
wrangler secret put DATABASE_PASSWORD   --env staging

wrangler secret put DATABASE_HOST       --env production
wrangler secret put DATABASE_USERNAME   --env production
wrangler secret put DATABASE_PASSWORD   --env production

Never commit DATABASE_* values to wrangler.jsonc — they belong in secrets, not vars.

Database (Neon + Drizzle)

The DB module follows the deep-modules pattern: every domain has its own folder with a narrow public API.

src/db/client/
├── table.ts      # pgTable definition
├── schema.ts     # Zod schemas for input/output
├── queries.ts    # getClients, getClient, createClient, updateClient, deleteClient
└── index.ts      # Public re-exports
  • initDatabase() is called once per Worker isolate from src/server.ts.
  • Every query calls getDb() — never pass the DB as a parameter.
  • Inputs are validated with Zod at the API boundary; mutations use .returning() to avoid extra round trips.

Migration Workflow

Each environment has its own Drizzle config (drizzle-{env}.config.ts) and migration directory (src/db/migrations/{env}/).

Migration directories

Only the development directory ships with the template. The others are created the first time you generate migrations for that environment — there is nothing to commit until you provision it.

Environment Directory Status
dev src/db/migrations/dev In the repository
staging src/db/migrations/staging Created by pnpm db:generate:staging
production src/db/migrations/production Created by pnpm db:generate:production
# 1. Edit your table definition in src/db/{domain}/table.ts
# 2. Generate a migration for the target environment
pnpm db:generate:dev
pnpm db:generate:staging
pnpm db:generate:production

# 3. Apply it
pnpm db:migrate:dev
pnpm db:migrate:staging
pnpm db:migrate:production

# Pull schema from an existing database
pnpm db:pull:dev

# Seed sample data
pnpm db:seed:dev

# Inspect data
pnpm db:studio

Per-env configs (drizzle-dev.config.ts, drizzle-staging.config.ts, drizzle-production.config.ts) all point at src/db/schema.ts but write migrations to separate directories, allowing independent migration tracking per environment.

REST API with Hono

All /api/* routes are handled by Hono. Endpoints live in src/hono/api/ and are mounted in src/hono/api.ts.

Example: GET /api/clients

// src/hono/api/clients.ts
import { isUniqueViolation } from "@/core/errors";
import {
  ClientCreateRequestSchema,
  createClient,
  getClients,
  PaginationRequestSchema,
} from "@/db/client";
import { createHono } from "@/hono/factory";

const clientsEndpoint = createHono();

clientsEndpoint.get("/", async (c) => {
  const parsed = PaginationRequestSchema.safeParse({
    limit: c.req.query("limit"),
    offset: c.req.query("offset"),
  });
  if (!parsed.success) return c.json({ error: parsed.error.message }, 400);
  return c.json(await getClients(parsed.data));
});

clientsEndpoint.post("/", async (c) => {
  const parsed = ClientCreateRequestSchema.safeParse(await c.req.json());
  if (!parsed.success) return c.json({ error: parsed.error.message }, 400);

  try {
    return c.json(await createClient(parsed.data), 201);
  } catch (err) {
    if (isUniqueViolation(err)) return c.json({ error: "Email already exists" }, 409);
    throw err;
  }
});

export default clientsEndpoint;

Mounting a New Endpoint

// src/hono/api.ts
import { createHono } from "./factory";
import clientsEndpoint from "@/hono/api/clients";
import healthEndpoint from "@/hono/api/health";

export const apiHono = createHono().basePath("/api");

apiHono.route("/health", healthEndpoint);
apiHono.route("/clients", clientsEndpoint);

The createHono() factory types Bindings: Env so c.env is fully typed against your Cloudflare configuration.

Hono vs TanStack Server Functions

Use Hono REST APIs Use TanStack Server Functions
Public APIs for external clients Server logic called from React
Webhooks Form submissions
Third-party integrations Data fetching for UI
Anything with a URL contract Type-safe client↔server calls

Security posture

The demo API is public, unauthenticated create-read-update-delete. It must not ship as-is.

Every /api/* route answers any request that reaches the Worker — including POST, PUT and DELETE on /api/clients. Anyone who knows the URL can write to and delete from your database. That is deliberate: these routes are scaffolding you delete, and a token check shipped in a template invites being mistaken for something production-grade. So no authentication is implemented here. What ships instead is the seam it attaches to.

Authentication attaches in src/hono/factory.ts. createHono() applies every middleware it is handed to *, ahead of any handler the endpoint registers:

import { type ApiMiddleware, createHono } from "@/hono/factory";

const requireApiKey: ApiMiddleware = async (c, next) => {
  if (c.req.header("authorization") !== `Bearer ${c.env.API_TOKEN}`) {
    return c.json({ error: "Unauthorized" }, 401);
  }
  await next();
};

const clientsEndpoint = createHono(requireApiKey);

Attach it per endpoint, or on the createHono() call in src/hono/api.ts to cover every mounted route at once. Existing call sites are untouched — createHono() with no arguments is exactly what it was, an unauthenticated endpoint.

Before you deploy:

  • Delete the demo surface or put authentication in front of it — src/db/client/, src/hono/api/clients.ts, and the /clients mount in src/hono/api.ts.
  • Decide what /api/health/ready may disclose. It currently returns the environment name and database reachability to anyone who asks.
  • Add cross-origin configuration if browsers on other origins will call this API. None is configured, so none is applied.
  • Add rate limiting. There is none, and a public write endpoint without it is a bill waiting to happen.
  • Set the Worker's secrets — see Secrets & Environments.

Token validation, session handling, cross-origin configuration and rate limiting are deliberately absent rather than half-implemented: a seam you fill is honest, a partial implementation you inherit is not.

Error Handling

Error infrastructure lives in src/core/errors.ts:

export class AppError extends Error {
  constructor(
    message: string,
    public code: ErrorCode,
    public status: number = 500,
    public field?: string,
  ) { super(message); this.name = "AppError"; }
}

export type Result<T> = { ok: true; data: T } | { ok: false; error: AppError };

export function isUniqueViolation(error: unknown): boolean { /* ... */ }
  • Use AppError for known, recoverable failures.
  • Use Result<T> when a caller needs to branch on success/failure without throwing.
  • Check error.cause.code (not error.message) when inspecting Drizzle errors — the raw Postgres code lives on cause. isUniqueViolation() is the idiomatic way to detect 23505 conflicts.
  • Unexpected errors propagate to the Hono global onError handler.

See .claude/rules/error-handling.md for the full convention.

Server Functions & TanStack Query

Server functions run exclusively on the server with full type safety across the boundary:

// src/core/middleware/example-middleware.ts
export const exampleMiddleware = createMiddleware({ type: "function" }).server(
  async ({ next }) => next({ context: { data: "Context from middleware" } }),
);

// src/core/functions/example-functions.ts
const ExampleInputSchema = z.object({ exampleKey: z.string().min(1) });

export const exampleFunction = createServerFn()
  .middleware([exampleMiddleware])
  .inputValidator((data: z.infer<typeof ExampleInputSchema>) =>
    ExampleInputSchema.parse(data),
  )
  .handler(async (ctx) => {
    // ctx.data — validated input
    // ctx.context — middleware context
    return "Server response";
  });

Call them from components via TanStack Query:

import { useMutation } from "@tanstack/react-query";
import { exampleFunction } from "@/core/functions/example-functions";

function MyComponent() {
  const mutation = useMutation({ mutationFn: exampleFunction });
  return (
    <button
      onClick={() => mutation.mutate({ exampleKey: "Hello Server!" })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? "Loading..." : "Call Server Function"}
    </button>
  );
}

SSR hydration is wired up in src/integrations/tanstack-query/ — loaders can prefetch into the query cache and it streams down with the HTML.

Routing & UI

  • File-based routing — add files to src/routes/, the tree auto-generates to routeTree.gen.ts on dev/build. Never edit the generated file.
  • Root layoutsrc/routes/__root.tsx.
  • Shadcn/UI — add components with pnpx shadcn@latest add <component>. Configured via components.json (new-york style, Zinc base, CSS variables).
  • Tailwind v4 — configured through the @tailwindcss/vite plugin, no separate config file. Styles entrypoint: src/styles.css.

Testing

pnpm test           # run once
pnpm test:watch     # watch mode
pnpm test:coverage  # v8 coverage
  • Tests live next to source as *.test.ts / *.test.tsx.
  • Vitest globals are enabled — no need to import describe / it / expect.
  • Route files (src/routes/**) are excluded from test discovery.
  • Test at module boundaries (exported queries, HTTP requests, user interactions), not internals. See .claude/rules/deep-modules.md.

The suite is split across Vitest projects, all driven by that one pnpm test:

Project Runs Files
node Node src/**/*.test.ts, scripts/**/*.test.ts
workers workerd, via @cloudflare/vitest-pool-workers src/**/*.worker.test.ts
components jsdom src/**/*.test.tsx

The suffix picks the project, so there is nothing to configure per file: name a test *.test.tsx and it renders under a DOM with Testing Library, *.worker.test.ts and it runs in the Workers runtime, anything else and it runs in Node.

src/dom-shims.ts stands in for the browser APIs jsdom omits — ResizeObserver, which Radix primitives use to position themselves, and matchMedia, which the theme provider reads. src/components/theme/theme.test.tsx is the worked example: it opens the theme menu by keyboard and asserts the document actually darkens.

Name a file *.worker.test.ts and it runs inside the real Workers runtime with the bindings from wrangler.jsonc, reachable through cloudflare:test:

import { env, SELF } from "cloudflare:test";

const res = await SELF.fetch("https://example.com/api/health/live"); // real dispatch
expect(env.CLOUDFLARE_ENV).toBe("dev");                             // real binding

Secrets are never read from your .dev.vars for these — vitest.config.ts binds inert stand-ins, so the suite behaves the same on your machine and in CI. TanStack Start's server entry is stubbed in that project (it needs a full Start build to resolve); dispatch is what these tests are for, and the stub makes "this request reached the app, not the API" an exact assertion.

Add a project to vitest.config.ts to run tests under another environment — exclude src/routes/** and keep pnpm test the only entry point, both of which src/vitest-projects.test.ts enforces.

Agent Rules & Design Docs

This template is set up for agent-assisted development:

  • .claude/CLAUDE.md — project-wide instructions.
  • .claude/rules/ — topic rules (general.md, deep-modules.md, error-handling.md, atomic-imports.md, cloudflare-deployment.md, plus stack-specific rules under .claude/rules/db/ and .claude/rules/frontend/) that activate automatically based on the files being edited.
  • AGENTS.md — agent workflow guide.
  • docs/ — single source of truth for business requirements and design docs.

Not every agent tool is supported to the same degree, and guessing wrong wastes a session. The support level of each is stated in Agent support.

Decisions already made for you

Three choices this template made on your behalf are written down rather than left to be inferred from the configuration. Each records what was decided, why, and the conditions under which you should decide differently:

  • Database driver — why the fetch-based Neon driver rather than Hyperdrive.
  • Smart Placement — why it ships off, when to turn it on, and how to measure whether it helped.
  • Agent support — which agent tooling is first-class, best-effort, or unsupported.

Learn More

License

Open source under the MIT License.

About

Full-stack web app template — TanStack Start + Hono on Cloudflare Workers, Neon Postgres + Drizzle, shadcn/ui.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages