Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ lerna-debug.log*
node_modules
.turbo
dist
packages/api/dist
dist-ssr
*.local
.wrangler
.dev.vars*
worker-configuration.d.ts
.data

# Editor directories and files
.vscode/*
Expand Down
81 changes: 74 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ This template is a PNPM/Turbo monorepo:
- `packages/api`: the Hono (OpenAPI) app — the single source of truth for the API
- `packages/cli`: the `template` CLI — dev servers and typed API access
- `apps/frontend`: TanStack Start React app that mounts `packages/api` at `/api`
- `apps/server`: optional standalone Worker that serves the same API on its own
- `apps/server`: optional standalone host that serves the same API on its own

Local development is **Node**. Cloudflare is an optional deploy target, not the
runtime you start on.

`pnpm dev` runs `pnpm cli dev`, which starts only the frontend — it serves both
the app and the API from one Worker through portless with HTTPS enabled:
the app and the API from one Node process through portless with HTTPS enabled:

- `https://frontend.localhost` — the app
- `https://frontend.localhost/api` — Swagger UI for the same Hono app
Expand All @@ -20,24 +23,81 @@ On non-`main` branches the branch slug is prepended to the hostname, for example

The frontend uses TanStack server functions for app-owned reads and mutations,
and the Hono RPC client for the API. Because the API is mounted on the same
Worker, the client calls it on the current origin; set `VITE_API_BASE_URL` to
point it at a separately deployed API instead.
origin, the client calls it there; set `VITE_API_BASE_URL` to point it at a
separately deployed API instead.

`apps/server` exists for running or deploying the API on its own. It imports the
same `packages/api` app, so it needs no routes of its own:

```sh
pnpm dev:server # https://server.localhost
pnpm deploy:server
pnpm deploy:server # optional Cloudflare Worker
```

## Runtime split

Node is the default. Cloudflare is one flag away — the same code, on workerd:

```sh
pnpm dev # frontend on Node
pnpm dev:cf # frontend on Cloudflare (workerd)
pnpm dev:server # API on Node
pnpm dev:server:cf # API on Cloudflare (wrangler dev)
```

`pnpm dev --cloudflare` works too; `--cloudflare` is a flag on `pnpm cli dev`.
Hit `/api/stats` to see which runtime answered — `uptimeMode` reports
`long-running process` on Node and `per-request isolate` on workerd.

`packages/api` is a fetch handler. It reads a small `AppBindings` object
(`DATABASE_URL`, `REGION`, `RUNTIME`) and never imports wrangler, Worker types,
or `Env`. Only the host adapters change between runtimes:

| Host | Node (default) | Cloudflare |
| --- | --- | --- |
| `apps/frontend` | Vite middleware mounts the API | `src/server.ts` Worker entry, via `TARGET=cloudflare` |
| `apps/server` | `@hono/node-server` (`src/node.ts`) | `src/server.ts` Worker entry, via `wrangler dev` |

`TARGET=cloudflare` selects the Cloudflare pipeline at build/dev time. The
`dev:cf`, `build:cf`, and `deploy` scripts set it, so you should not need to
type it. Do not confuse it with the `RUNTIME` binding, which reports the
runtime actually serving a request (`node` or `workerd`).

To deploy to Cloudflare:

1. Keep writing routes in `packages/api`.
2. Add bindings in the app's `wrangler.jsonc` (`vars`, Hyperdrive, etc.).
3. Run `pnpm cf-typegen` to generate `worker-configuration.d.ts`. Do not
hand-write `Env`.
4. Map those generated bindings onto `AppBindings` in the app's Worker adapter
(`apps/server/src/server.ts` or `apps/frontend/src/server.ts`). For example,
Hyperdrive becomes `DATABASE_URL`.
5. Deploy with `pnpm deploy` / `pnpm deploy:server`.

Verify the Worker path without shipping anything:

```sh
pnpm --filter @template/frontend exec wrangler deploy --config dist/server/wrangler.json --dry-run
pnpm --filter @template/server exec wrangler deploy --config ./wrangler.jsonc --dry-run
```

The frontend deploys the *generated* `dist/server/wrangler.json`, not the root
`wrangler.jsonc`. The root file is the input the Vite plugin reads; wrangler
cannot bundle `src/server.ts` on its own because the TanStack Start entry only
exists inside the Vite build.

The same `DATABASE_URL` binding is the swap point for a later Postgres database
(PGlite locally, a real URL in production, Hyperdrive on Workers). Do not
auto-migrate on startup.

## CLI

`packages/cli` is exposed at the repo root as `pnpm cli`:

```sh
pnpm cli dev # frontend on portless, opens the browser when ready
pnpm cli dev --no-open # …without opening the browser
pnpm cli dev --cloudflare # …on workerd instead of Node
pnpm cli dev server # apps/server instead
pnpm cli api stats # GET /api/stats
pnpm cli api stats --json # raw JSON
Expand All @@ -51,6 +111,9 @@ answer and opens it in a browser, then removes the alias on exit. Running an
app's `dev` script directly (`pnpm --filter @template/frontend dev`) skips
portless and serves plain HTTP on the app's port.

`--cloudflare` runs each app's `dev:cf` script instead of `dev`, so the portless
URL and browser handoff are identical on either runtime.

The `api` commands call the same Hono app through the typed RPC client, so they
stay in sync with `packages/api`. They target `$TEMPLATE_API_BASE_URL`, or the
portless frontend URL, or `--base-url`. Node does not read the system trust
Expand All @@ -64,13 +127,17 @@ bundles it with tsdown, inlining workspace dependencies so `commander` and
## Commands

```sh
pnpm dev
pnpm dev # frontend on Node
pnpm dev:cf # frontend on Cloudflare
pnpm dev:server # API on Node
pnpm dev:server:cf # API on Cloudflare
pnpm cli
pnpm build
pnpm typecheck
pnpm lint
pnpm fmt
pnpm deploy
pnpm deploy # frontend → Cloudflare
pnpm deploy:server # API → Cloudflare
```

Regenerate Worker environment types after changing either app's `wrangler.jsonc`:
Expand Down
68 changes: 68 additions & 0 deletions apps/frontend/hono-api-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { getRequestListener } from '@hono/node-server'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Connect, Plugin } from 'vite'

type ApiModule = typeof import('@template/api')

function isApiModule(value: unknown): value is ApiModule {
return (
typeof value === 'object' &&
value !== null &&
'app' in value &&
'isApiPath' in value &&
'bindingsFromRecord' in value
)
}

async function loadApiModule(load: () => Promise<unknown>): Promise<ApiModule> {
const value = await load()

if (!isApiModule(value)) {
throw new Error('Failed to load the Hono API module')
}

return value
}

function mountApi(middlewares: Connect.Server, load: () => Promise<unknown>) {
middlewares.use(async (req: IncomingMessage, res: ServerResponse, next) => {
const path = (req.url ?? '/').split('?')[0] ?? '/'

try {
const api = await loadApiModule(load)

if (!api.isApiPath(path)) {
next()
return
}

const listener = getRequestListener((request) =>
api.app.fetch(
request,
api.bindingsFromRecord({ ...process.env, RUNTIME: 'node' }),
),
)

await listener(req, res)
} catch (error) {
next(error)
}
})
}

/** Serves `packages/api` at `/api` on the Node Vite server. */
export function honoApi(): Plugin {
return {
name: 'hono-api',
configureServer(server) {
// Load through Vite so the TypeScript workspace package is transformed.
mountApi(server.middlewares, () => server.ssrLoadModule('@template/api'))
},
configurePreviewServer(server) {
mountApi(server.middlewares, async () => {
const { tsImport } = await import('tsx/esm/api')
return tsImport('../../packages/api/src/index.ts', import.meta.url)
})
},
}
}
7 changes: 5 additions & 2 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
"type": "module",
"scripts": {
"dev": "vite dev --host ${HOST:-127.0.0.1} --port ${PORT:-5173}",
"dev:cf": "TARGET=cloudflare vite dev --host ${HOST:-127.0.0.1} --port ${PORT:-5173}",
"build": "vite build && tsc -b",
"build:cf": "TARGET=cloudflare vite build && tsc -b",
"typecheck": "tsc -b",
"preview": "vite preview",
"deploy": "pnpm build && wrangler deploy --config ./wrangler.jsonc",
"deploy": "pnpm build:cf && wrangler deploy --config dist/server/wrangler.json --no-install-skills",
"cf-typegen": "wrangler types --config ./wrangler.jsonc"
},
"dependencies": {
Expand All @@ -17,6 +19,7 @@
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8",
"@tanstack/react-router-devtools": "^1.167.0",
"@hono/node-server": "^1.19.9",
"@tanstack/react-start": "^1.168.25",
"@template/api": "workspace:*",
"class-variance-authority": "^0.7.1",
Expand All @@ -41,7 +44,6 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.39.0",
"@cloudflare/workers-types": "^4.20260528.1",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/router-plugin": "^1.168.11",
"@types/node": "^24.12.3",
Expand All @@ -50,6 +52,7 @@
"@vitejs/plugin-react": "^6.0.1",
"shadcn": "^4.8.2",
"tailwindcss": "^4.3.0",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^8.0.12",
"wrangler": "^4.95.0"
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/lib/api-url.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// The Hono API is mounted on this Worker at `/api` (see `src/server.ts`), so
// requests default to the current origin. Set `VITE_API_BASE_URL` to point at a
// The Hono API is mounted on this origin at `/api` (Vite middleware on Node,
// `src/server.ts` on Cloudflare). Set `VITE_API_BASE_URL` to point at a
// separately deployed API instead (for example `apps/server`).
const configuredApiBaseUrl = import.meta.env.VITE_API_BASE_URL

Expand Down
6 changes: 2 additions & 4 deletions apps/frontend/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const Route = createRootRoute({
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{ title: 'CF Hono Starter' },
{ title: 'Template' },
],
}),
component: RootLayout,
Expand All @@ -39,9 +39,7 @@ function RootLayout() {
<header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 sm:px-6">
<div className="grid gap-0.5">
<strong className="text-sm font-medium">
CF Hono Starter
</strong>
<strong className="text-sm font-medium">Template</strong>
<span className="text-xs text-muted-foreground">
TanStack Start + Hono API
</span>
Expand Down
15 changes: 11 additions & 4 deletions apps/frontend/src/routes/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,21 @@ function About() {
<CardHeader>
<CardTitle>About this stack</CardTitle>
<CardDescription>
A Cloudflare Workers app with separate TanStack Start and Hono apps.
A Node-first app with a portable Hono API and an optional Cloudflare
adapter.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 text-sm text-muted-foreground">
<p>
This project targets Cloudflare Workers. TanStack Start renders the
React frontend and owns app-specific server functions, while Hono
runs as a standalone API.
Local development runs on Node. TanStack Start renders the React
frontend and owns app-specific server functions, while Hono stays a
standalone fetch-based API.
</p>
<p>
The same API can be deployed as a Cloudflare Worker later without
changing route code. Worker bindings are generated with wrangler and
mapped in a thin adapter — they never leak into{' '}
<code>packages/api</code>.
</p>
<p>
The frontend reads the single Hono stats endpoint with React Query.
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function Dashboard() {
<main className="grid gap-6">
<section className="grid max-w-3xl gap-3">
<Badge variant="secondary" className="w-fit">
Cloudflare-compatible full stack app
Node-first, Cloudflare-ready
</Badge>
<h1 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
TanStack Start owns app actions; Hono remains a separate API.
Expand Down
20 changes: 15 additions & 5 deletions apps/frontend/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import handler from '@tanstack/react-start/server-entry'
import { apiBasePath, app as api } from '@template/api'
import { app as api, bindingsFromRecord, isApiPath } from '@template/api'

/**
* Optional Cloudflare Worker adapter. Local `pnpm dev` uses Vite + Node and
* mounts the API through `hono-api-plugin.ts` instead.
*
* After adding bindings, run `pnpm cf-typegen` and map them here. Do not
* import wrangler-generated `Env` from `packages/api`.
*/
export default {
fetch(request, env, ctx) {
fetch(request: Request, env: Record<string, unknown>) {
const { pathname } = new URL(request.url)

if (pathname === apiBasePath || pathname.startsWith(`${apiBasePath}/`)) {
return api.fetch(request, env, ctx)
if (isApiPath(pathname)) {
return api.fetch(
request,
bindingsFromRecord({ ...env, RUNTIME: 'workerd' }),
)
}

return handler.fetch(request)
},
} satisfies ExportedHandler<Env>
}
1 change: 1 addition & 0 deletions apps/frontend/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
4 changes: 2 additions & 2 deletions apps/frontend/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client", "@cloudflare/workers-types"],
"types": ["vite/client"],
"skipLibCheck": true,

/* Bundler mode */
Expand All @@ -26,5 +26,5 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "worker-configuration.d.ts"]
"include": ["src"]
}
2 changes: 1 addition & 1 deletion apps/frontend/tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "hono-api-plugin.ts"]
}
Loading