This document captures the architecture of this repository as a reference implementation. The goal is that a human or agent picking up a greenfield app can use it as an archetype: the choices, the seams, the gotchas, and the reasons behind them. It is not a tutorial on any one tool — it is a guide to how these tools compose, and what to do (and not do) at each seam.
The stack:
- TanStack Start — SSR framework + server functions (RPC).
- TanStack Router — file-based router with loaders and typed search params.
- TanStack React Query — client cache, hydrated from the SSR render.
@tanstack/react-router-ssr-query— the wire that dehydrates the QueryClient on the server and rehydrates it on the client.- Convex — backend as a service: database, serverless functions, realtime subscriptions, codegen.
@convex-dev/react-query— adapter that lets Convex queries be expressed as React Query options (with realtime updates wired in via WebSocket on the client).- Cloudflare Workers — the SSR runtime, via
@cloudflare/vite-pluginandwrangler. - Vite (vite-plus) — dev server, build, task runner.
- Tailwind CSS v4 — styling via
@tailwindcss/vite, with design tokens declared in@themeand component-local utility classes everywhere else. - Valibot +
@conform-to/valibot— schema validation at all the boundaries (search params, form data, env vars).
If you are building a new app on this stack, the patterns here should transfer 1:1. If you are extending this app, read § Conventions & Gotchas first.
This is the single most load-bearing architectural commitment in the codebase. Internalize it before reading anything else.
Every user-visible action — clicking a button, submitting a form, navigating between pages, toggling a favorite, deleting a record — works when JavaScript is disabled, broken, slow to load, or in the middle of hydrating. JS is an enhancement, not a requirement.
Concretely, this means:
- Buttons that perform actions are
<form>s, not<button onClick>s. A bareonClickis dead without JS; a<form action="/...">posts to the Worker and the browser navigates. SeeNewButton,DeleteButton,EditButton, andFavoriteButton— they are all<form>elements. - Navigation that mutates state is a form post. "Edit this contact"
is a
<form method="get" action="/contact/:id/edit">. Even though it's just a navigation, modeling it as a form means there is one consistent affordance and no ambiguity about JS dependency. - Mutations are server functions with stable URLs. Every
createServerFn({ method: "POST" })produces a.urlyou can put in a<form action>. The Worker handles the POST whether the request arrives from a JS-enhanced submit or a vanilla form. The handler does not know or care which path the request came from — it validates the sameFormData, performs the same mutation, throws the sameredirect(...). - The JS enhancement is layered on top. Each form has an
onSubmitthat callsevent.preventDefault()and routes throughuseServerFn(...). If JS is missing, the browser ignoresonSubmitand submits natively. If JS is present, the enhanced path runs and the user gets client-side navigation, optimistic UI, and confirm dialogs.
The architectural payoff is not abstract:
- First paint is interactive. Users can click "Delete" before hydration finishes; the form posts to the Worker exactly as it would post-hydration.
- Bots, screen readers, and "Reader Mode" all work. Native form submission is the lowest-common-denominator browser primitive.
- Failures are graceful. If a route's JS chunk fails to load, the app still functions.
- Mental model stays simple. There is one canonical mutation path (Worker handler over HTTP). The JS enhancement reuses it, never forks it.
The corollary, which is just as important: do not introduce code paths
that only work with JS. If you find yourself reaching for
<button onClick> for anything other than purely client-side UI
(opening a menu, focusing an input), stop and ask whether a <form>
would do. The answer is almost always yes.
There is exactly one operation in this archetype that requires JS: the
favorite toggle uses a direct Convex useMutation for optimistic
updates (FavoriteButton). Even that one
is still a <form action={toggleFavorite.url}> — without JS, it
degrades to a normal server-function POST that re-renders the page.
The optimistic flicker is the enhancement; correctness lives in the
server function.
┌─────────────────────────────────────────────────────────────────┐
│ Browser │
│ - React + TanStack Router (client routing, hydration) │
│ - TanStack Query cache (hydrated from SSR, then live) │
│ - ConvexReactClient (WebSocket → realtime updates) │
└──────────────┬────────────────────────────┬─────────────────────┘
│ │
│ HTTP (server fns) │ WebSocket (Convex)
│ │
┌──────────────▼────────────────┐ ┌───────▼──────────────────────┐
│ Cloudflare Worker (SSR) │ │ Convex (managed backend) │
│ - TanStack Start server │ │ - Database │
│ entry │ │ - Queries/mutations/actions │
│ - Server function handlers │ │ - Reactive subscriptions │
│ - ConvexHttpClient on SSR │──▶│ │
│ + per-request QueryClient │ │ │
└───────────────────────────────┘ └──────────────────────────────┘
The Worker renders HTML and runs server functions. Convex owns persistence and push-based updates. The browser bridges the two via React Query + a Convex client. Do not put long-lived state on the Worker. A Worker invocation is ephemeral; durable state lives in Convex.
.
├── app/
│ ├── root.tsx # Root route: <html>, layout, sidebar, search
│ ├── router.tsx # createRouter + provider wiring
│ ├── routes.ts # Virtual route config (input)
│ ├── routes.gen.ts # Generated route tree (do not edit)
│ ├── routes/ # Route components (referenced by routes.ts)
│ │ ├── empty.tsx
│ │ ├── show.tsx
│ │ └── edit.tsx
│ ├── components/ # Presentation; no data fetching
│ ├── data/
│ │ ├── convex.ts # Client factories (HTTP + React)
│ │ ├── queries.ts # convexQuery() options builders
│ │ ├── mutations.ts # createServerFn() handlers
│ │ └── seed.ts # Dev-only seeding
│ ├── lib/
│ │ ├── schemas.ts # Valibot validators + parseEnv
│ │ ├── href.ts # useHref() helper
│ │ └── hooks.ts # Shared client hooks
│ └── styles/index.css
├── convex/
│ ├── schema.ts # defineSchema (tables + types)
│ ├── contacts.ts # query/mutation handlers
│ └── _generated/ # convex codegen (do not edit)
├── vite.config.ts # tanstackStart + cloudflare + vite-plus tasks
├── wrangler.jsonc # CF Worker config; main = TSS server entry
├── worker-configuration.d.ts # Generated by `wrangler types`
├── tsconfig.json
└── package.json # `imports`: #/* and #convex/*
Use Node import aliases declared in package.json:
"imports": {
"#/*": "./app/*",
"#convex/*": "./convex/*"
}Then:
import { api } from "#convex/_generated/api.js";
import { getContactQuery } from "#/data/queries.ts";Prefer #/... over deep relative imports (../../..). Use .ts/.tsx
extensions in imports — the lint config enforces this (import/extensions).
Routes are declared in app/routes.ts and a route tree is generated to
app/routes.gen.ts. The generator is the TanStack Start Vite plugin; route
files only need to live wherever routes.ts points.
// app/routes.ts
import { rootRoute, route, index } from "@tanstack/virtual-file-routes";
export let routes = rootRoute("root.tsx", [
index("routes/empty.tsx"),
route("contact/$id", "routes/show.tsx"),
route("contact/$id/edit", "routes/edit.tsx"),
]);Notes:
- The
routesDirectory: "."invite.config.tsplusvirtualRouteConfig: "app/routes.ts"means we opt out of filename-based routing in favor of explicit config. This makes route → file mapping explicit and refactor-friendly. routes.gen.tsis gitignored and rebuilt by the plugin. Never edit it.- The
Registermodule augmentation inroutes.gen.tsdeclaresssr: truefor@tanstack/react-start, which is how server functions know they run in an SSR environment.
router.tsx is the single composition root. It is called by TanStack Start on
both the server (once per request) and the client (once per page load).
// app/router.tsx
export function getRouter() {
let { convex, queryClient } = createConvexQueryClient();
function Wrap({ children }: PropsWithChildren) {
return (
<ConvexProvider client={convex}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ConvexProvider>
);
}
let router = createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
context: { queryClient },
Wrap,
});
setupRouterSsrQueryIntegration({ router, queryClient });
return router;
}Key decisions:
- A fresh
QueryClientpergetRouter()call. On the server this means per-request isolation (no cache bleed between users). On the client it means one client for the lifetime of the page. Never hoist the QueryClient to module scope. - Same
Wrapruns in both environments. Both providers wrap every render so hooks likeuseMutationfromconvex/reactanduseSuspenseQueryfrom React Query always have a context. context: { queryClient }exposes the client to loaders so they can callqueryClient.ensureQueryData(...)without importing a singleton.setupRouterSsrQueryIntegrationis the magic glue: it serializes the React Query cache into the HTML on the server and rehydrates it in the browser. Without it, everyuseSuspenseQueryin a loader-prefetched route would re-fetch on the client.defaultPreload: "intent"kicks loaders on link hover/focus.scrollRestoration: truerestores scroll on back/forward.
The root must be created via createRootRouteWithContext so the context type
propagates through the tree:
// app/root.tsx
export let Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
/* ... */
});The canonical route loader uses queryClient.ensureQueryData with a query
options object exported from app/data/queries.ts:
// app/routes/show.tsx
export let Route = createFileRoute("/contact/$id")({
async loader({ context: { queryClient }, params }) {
let contact = await queryClient.ensureQueryData(getContactQuery(params.id));
if (!contact) throw notFound();
},
component: ShowContact,
});
function ShowContact() {
let params = Route.useParams();
let { data: contact } = useSuspenseQuery(getContactQuery(params.id));
/* ... */
}Why this is the canonical shape:
- The loader populates the cache; the component reads from it. The component does not receive data via props from the loader (TanStack Router can do that, but it bypasses React Query and loses the realtime channel). Both use the same query options builder, so the cache key is guaranteed identical.
ensureQueryDatais the SSR-safe call. It returns the existing value if already cached, otherwise fetches and caches. During SSR it produces the promise thatsetupRouterSsrQueryIntegrationserializes into the HTML.useSuspenseQueryin the component is what lets@convex-dev/react-queryswap the HTTP-fetched value for a live WebSocket subscription on the client once mounted. Combined with hydration this means: server renders the contact, browser receives the HTML and a live subscription pointed at the same query.throw notFound()from the loader if the row doesn't exist. The router renders the closestnotFoundComponent. Re-check in the component too, because realtime updates can delete the row out from under you while the page is open.
Validate search params at the route boundary. Use schema, not ad-hoc parsing:
// app/root.tsx
export let Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
validateSearch: fromSearch<{ q?: string }>()(QuerySchema),
loaderDeps: ({ search: { q } }) => ({ q }),
loader: ({ context: { queryClient }, deps: { q } }) =>
queryClient.ensureQueryData(listContactsQuery(q)),
component: Root,
});validateSearchruns on every navigation; output is typed.loaderDepsdeclares which slice of search params triggers a loader re-run. Without it the loader would re-run on every search change.- The component reads
Route.useLoaderDeps()(orRoute.useSearch()) instead of parsingwindow.location.
The edit route uses the verbose loader form to tune reload semantics:
loader: {
handler: async ({ context: { queryClient }, params }) => { /* ... */ },
staleReloadMode: "blocking",
},staleReloadMode: "blocking" waits for fresh data before swapping in the
edit screen, so a stale value never flashes into form defaults. The show
route doesn't need this because the live subscription updates the rendered
contact in place.
The clever bit of this stack is that all reads go through React Query, but the actual transport on the client is a Convex WebSocket subscription. You write code that looks like React Query, and Convex makes it live.
// app/data/convex.ts
export function createConvexQueryClient() {
let convex = new ConvexReactClient(VITE_CONVEX_URL);
let convexQueryClient = new ConvexQueryClient(convex);
let queryClient = new QueryClient({
defaultOptions: {
queries: {
queryKeyHashFn: convexQueryClient.hashFn(),
queryFn: convexQueryClient.queryFn(),
experimental_prefetchInRender: true,
},
},
});
convexQueryClient.connect(queryClient);
return { convex, queryClient, convexQueryClient };
}
export function createConvexHttpClient() {
return new ConvexHttpClient(VITE_CONVEX_URL);
}ConvexReactClient+ConvexQueryClient— used in the route/component layer. The query client uses Convex'shashFnandqueryFnas defaults, so anyconvexQuery({api fn}, args)works without per-call setup. On the client this client maintains a WebSocket; reads stay live.ConvexHttpClient— used in server functions. A Worker handler is a one-shot HTTP request; opening a WebSocket inside it would be wasteful and wrong. Mutations and ad-hoc reads from server functions go over HTTP.
Rule: Inside a server function or any module that runs only on the server,
use createConvexHttpClient(). Inside a React component or any code that
might run on the client, use the React Query options pattern.
// app/data/queries.ts
import { convexQuery } from "@convex-dev/react-query";
import { api } from "#convex/_generated/api.js";
export function getContactQuery(id: string) {
return convexQuery(api.contacts.get, { id: id as Id<"contacts"> });
}
export function listContactsQuery(q?: string) {
return convexQuery(api.contacts.list, { q });
}- One builder per Convex query. Export from
app/data/queries.ts. Never inlineconvexQuery(...)calls in components — the loader and the component must produce the same options object so the query key matches. api.contacts.listis the type-safe reference to a Convex function. The generated types inconvex/_generated/api.d.tsmakeargsand return value fully typed end-to-end.
// convex/contacts.ts
export let list = query({
args: { q: v.optional(v.string()) },
handler: async (ctx, { q }) => {
let rows = await ctx.db.query("contacts").collect();
if (q) rows = matchSorter(rows, q, { keys: ["first", "last"] });
return sortBy(rows, ["last", "_creationTime"]);
},
});argsis a Convex validator (not Valibot). It governs the wire format and gives the codegen its types.- Use
v.id("contacts")for typed document IDs. Server functions on the start side typecaststring→Id<"contacts">; Convex validates at the boundary. - Reactive subscriptions are automatic — every
useQuery/useSuspenseQuerythrough@convex-dev/react-queryis a live subscription.
// convex/schema.ts
export default defineSchema({
contacts: defineTable({
first: v.string(),
last: v.string(),
avatar: v.optional(v.string()),
bsky: v.string(),
notes: v.string(),
favorite: v.boolean(),
}),
});The schema is the source of truth for stored shape. Doc<"contacts"> and
Id<"contacts"> are derived in convex/_generated/dataModel.d.ts. Import
those types into the React code — do not redeclare them.
There are two valid ways to mutate data in this stack, and choosing between them is the single most important architectural decision per feature.
Server function (createServerFn) |
Convex mutation (useMutation) |
|
|---|---|---|
| Runs in | Cloudflare Worker | Browser (sends RPC to Convex) |
| Endpoint URL | serverFn.url (Worker route) |
None (Convex client) |
| Input | FormData or JSON, validated by Valibot |
Typed args, validated by Convex v |
| Works without JS | Yes (HTML form posts) | No |
| Redirect support | Yes (throw redirect(...)) |
No |
| Optimistic updates | Manual | Built in (.withOptimisticUpdate) |
| Reads | Via ConvexHttpClient |
N/A |
Heuristic:
- Default to a server function. It gives you a stable URL for
<form action>, redirects, and a no-JS path by default. The prime directive (§ 0) makes this the path of least resistance. - Layer a direct Convex
useMutationon top only when you need optimistic UI the user will notice on slow networks. Even then, keep the form'sactionpointing at a server function so the non-JS path still works. The favorite toggle inFavoriteButtonis the canonical example:<form action={toggleFavorite.url}>for correctness, plus a ConvexuseMutation().withOptimisticUpdate(...)invoked fromonSubmitfor the snappy UX.
This app does both. See § 6 for examples.
// app/data/mutations.ts
export let destroyContact = createServerFn({ method: "POST" })
.inputValidator(fromInput<FormData>()(IdSchema))
.handler(async ({ data }) => {
let convex = createConvexHttpClient();
await convex.mutation(api.contacts.destroy, { id: data.id as Id<"contacts"> });
throw redirect({ to: "/" });
});Conventions:
- Declare method explicitly (
POST). The formactionandmethodcome fromserverFn.url/serverFn.method(see § 6). - Validate input with Valibot;
fromInput<FormData>()(Schema)lets the validator parse aFormDataobject directly via@conform-to/valibot'sparseWithValibot. - Throw
redirect(...)instead of returning a Response. TanStack Start unwinds it correctly on both the JS and non-JS code paths. - Throw
notFound()for 404s. Same reasoning. - Open a fresh
ConvexHttpClientper call. Cheap, and avoids hidden state. - Cast string IDs to
Id<"contacts">at the Convex boundary. Convex re-validates so the cast is safe.
// app/components/buttons.tsx — FavoriteButton
let toggle = useMutation(api.contacts.update).withOptimisticUpdate((localStore, args) => {
let favorite = args.favorite;
if (favorite === undefined) return;
let contact = localStore.getQuery(api.contacts.get, { id: args.id });
if (contact) {
localStore.setQuery(api.contacts.get, { id: args.id }, { ...contact, favorite });
}
});localStore is Convex's local query cache (separate from React Query's). The
optimistic value applies until the mutation acks, after which the
subscription pushes the canonical value. There is no manual rollback.
Re-read § 0 if you haven't. This section is the concrete implementation of that prime directive.
The pattern: every interactive element is a <form> whose action is a
server-function URL. JS, when present, intercepts onSubmit and routes
through useServerFn(...). JS, when absent, the browser submits natively
and the Worker handles the request identically. There is no second
mutation code path.
// app/components/buttons.tsx — DeleteButton
export function DeleteButton(props: ComponentProps<"form"> & { id: string }) {
let destroy = useServerFn(destroyContact);
return (
<form
action={destroyContact.url}
method={destroyContact.method}
onSubmit={async event => {
event.preventDefault();
if (!confirm("Please confirm you want to delete this record.")) return;
await destroy({ data: new FormData(event.currentTarget) });
}}
{...props}
>
<input name="id" type="hidden" value={props.id} />
<button type="submit">Delete</button>
</form>
);
}Why it's shaped this way:
action={serverFn.url}+method={serverFn.method}mean the form works without JavaScript. The Worker handles the POST natively, the server function redirects, and the browser follows. End to end, no JS involved.useServerFn(destroyContact)gives a callable that JS-enhances the submit. AfterpreventDefault()we hand the rawFormDatato the same handler. Same validator, same code path, same redirect — but no full page reload, no scroll reset, and any optimistic UI applies.- Hidden inputs carry IDs. This is the universal pattern — works with
FormDatawhether the request is JS or HTML. Never read IDs from closures; serialize them into the form so the no-JS path has them too. - Confirm dialogs and other UX wrap the JS path but the no-JS fallback still works (it just won't ask). That's an acceptable degradation.
- No
onClickaction buttons. A click handler on a bare<button>is dead without JS. Every action button in this app is a<form>with a submit button inside.<button type="submit">is the affordance; the form is the action.
Likewise for navigations triggered by buttons (e.g., the "Edit" button in
EditButton):
let options = linkOptions({ to: "/contact/$id/edit", params: { id: props.id } });
let editHref = useHref(options);
return (
<form
action={editHref}
method="get"
onSubmit={async event => {
event.preventDefault();
await navigate(options);
}}
>
<button type="submit">Edit</button>
</form>
);The form is a GET form — without JS the browser navigates by appending
the form data to the URL (here, none). With JS, navigate() does a
client-side transition. Same destination either way. The same
linkOptions are reused for both useHref (the no-JS action) and
navigate (the JS path), so there is one typed source of truth.
The edit form uses the same pattern (EditContactForm wraps the
updateContact server fn). Defaults come from the loader-cached contact via
useSuspenseQuery. Fields are uncontrolled (defaultValue) — the form is
submitted via new FormData(event.currentTarget) so React doesn't need to
own the values.
SearchForm does not post; it updates the URL query string. Three
patterns worth copying:
- Local state for the input, URL for the source of truth. Router
transitions wrap navigations, so
Route.useSearch()lags the keystroke. A controlled input bound to URL state would visually reset between keystrokes. Fix: hold an immediate localvalue, sync from URL on external navigation only. - Adjust state during render (
if (query !== syncedQuery) { setSyncedQuery(query); setValue(query ?? ""); }) instead ofuseEffect. This is React's officially-blessed pattern for syncing derived state. See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes - Push vs replace. First keystroke pushes a history entry; subsequent
keystrokes replace. The
liveRefis necessary becausevaluelags inside a single render burst — without it, the push/replace decision would be wrong on rapid typing.
// app/lib/hooks.ts
export function useNavigating(): boolean {
let { isLoading, location, resolvedLocation } = useRouterState();
return isLoading && location.pathname !== resolvedLocation?.pathname;
}True only when the pathname is changing (not just search params). Use it
to fade content during route transitions without flickering on every
keystroke in the search box. See Details, SidebarItem, SearchForm.
// app/lib/href.ts
export function useHref<const TOptions>(
options: ValidateLinkOptions<RegisteredRouter, TOptions, string, "a">,
): string {
let router = useRouter();
return router.buildLocation(options as ToOptions).href;
}Lets you produce a typed href string for use in <form action> or <a href>
where you can't use <Link>. Pair with linkOptions({...}) to keep one
typed source of route options and reuse it in navigate() calls.
A request to /contact/abc123:
- Cloudflare Worker invokes TanStack Start's server entry (configured via
wrangler.jsonc's"main": "@tanstack/react-start/server-entry"). - Start calls
getRouter(). A freshQueryClient+ConvexReactClientare constructed. - Router matches
/contact/$id. Loaders run:- Root loader:
ensureQueryData(listContactsQuery(q))— populates the contacts list. - Show route loader:
ensureQueryData(getContactQuery("abc123"))— fetches the one contact. - These hit Convex over the React Query
queryFn(which is Convex's under the hood). On the server it uses HTTP under the covers.
- Root loader:
- React renders the route tree to HTML.
useSuspenseQueryfinds cached values from step 3, so no suspending occurs. setupRouterSsrQueryIntegrationdehydrates the QueryClient and inlines the cache into the HTML.- Worker streams the HTML to the browser.
- Browser hydrates.
getRouter()runs again — freshQueryClient—setupRouterSsrQueryIntegrationrehydrates from the inlined cache. ConvexReactClientconnects via WebSocket. Each activeuseSuspenseQuerybecomes a live subscription. Mutations on any connected client push deltas into the open subscriptions.
Implications:
- The initial page is fully rendered HTML. No client-side loading flash.
- After hydration the page is live. Edit a contact in another tab and the open tab updates without a refresh.
- If you bypass the loader (e.g.
useQuerywith a key the loader didn't prime), you'll get a client-side fetch and a loading state.
React 19 hoists <title>, <meta>, <link>, <script>, and <style>
tags that are rendered anywhere in the component tree up into the
document <head> automatically — on both the server (so the streamed
HTML contains them) and the client (so navigations update them).
Use this. It removes the need for a Helmet-style API.
Concrete patterns used in this archetype:
-
Per-route titles — render
<title>inside the route component. SeeShowContact:function ShowContact() { let { data: contact } = useSuspenseQuery(getContactQuery(params.id)); return ( <div id="contact"> <title>{`${contact.first} ${contact.last} | TanStack Contacts`}</title> {/* ...rest of the page */} </div> ); }
The
<title>lives next to the data that produces it. React lifts it to<head>automatically. SSR includes it in the initial HTML; SPA navigations swap it. -
Stylesheets, favicons, and other
<link>tags — render them wherever they belong semantically (typically the root). SeeRoot:<head> <meta charSet="utf-8" /> <meta content="width=device-width, initial-scale=1" name="viewport" /> <link href={styles} rel="stylesheet" /> <link href="/favicon.ico" rel="icon" type="image/x-icon" /> <title>TanStack Contacts</title> </head>
The root happens to render them directly inside
<head>because it owns the document shell. But a route could equally render a<link rel="preload">for an above-the-fold image and React would hoist it. -
Per-page meta (OpenGraph, descriptions, canonical) — same pattern: render
<meta>and<link rel="canonical">inside the route component that owns that page. They will appear in the SSR HTML and update on navigation. -
Page-scoped
<style>and<script>tags — when a third-party embed (analytics script, structured-data JSON-LD) belongs to a specific route, render it inside that route. React handles dedup byhref/src.
Rules of thumb:
- Render at the deepest sensible component — the one that owns
the data the tag depends on. Don't centralize titles in the root and
pass strings down; let routes own their own
<title>. - Default titles in the root, specific titles in routes. The root
renders a generic
<title>TanStack Contacts</title>; a child route rendering<title>...</title>wins. React keeps the last-rendered one. - For meta tags that should aggregate (e.g. multiple OG tags), render them all — React will hoist each.
- No portals, no Helmet-style providers, no
useEffectsettingdocument.title. If you see those, replace them.
Caveat: nested <title> tags inside the body are valid for React's
hoisting but invalid HTML if they were to stay there. Don't worry —
React removes them from the original position when it hoists. The
mental model is "declare what you want in <head> from anywhere".
// app/lib/schemas.ts
import * as v from "valibot";
import { assert } from "@std/assert";
let EnvSchema = v.object({
DEV: v.boolean(),
SSR: v.boolean(),
VITE_CONVEX_URL: v.pipe(v.string(), v.url()),
});
export function parseEnv(env: Record<string, unknown>) {
let parsed = v.safeParse(EnvSchema, env);
/* ... */
assert(value, `\n\nMust provide the following environment variables:\n${issues.join("\n")}\n`);
return value;
}Pass import.meta.env in. Crashes loudly at startup if anything is missing.
Every module that reads an env var should go through parseEnv.
VITE_*env vars are inlined at build time and available to browser code.- Non-
VITE_*vars inwrangler.jsoncare runtime bindings on the Worker only. Read them through the Worker request context, notimport.meta.env. .env.localis for local dev; it is gitignored.
mainpoints at the TanStack Start server entry export, not a local file. The Vite + cloudflare plugins compose to produce a Worker bundle that runs through this entry.nodejs_compatis required for some Convex client internals.- Run
vpr typegen:cloudflare(alias forwrangler types) to regenerateworker-configuration.d.tswhenever you add a binding.
plugins: [
devtoolsJson(),
cloudflare({ viteEnvironment: { name: "ssr" } }),
tanstackStart({
srcDirectory: "app",
router: {
routesDirectory: ".",
virtualRouteConfig: "app/routes.ts",
generatedRouteTree: "routes.gen.ts",
},
}),
react(),
babel({ presets: [reactCompilerPreset()] }),
tailwindcss(),
],cloudflarefirst sets up the Worker SSR environment named"ssr". The TanStack Start plugin then targets that environment.react()aftertanstackStartso JSX is transformed correctly.babelwith the React Compiler preset enables auto-memoization. Keep components straightforward; trust the compiler.tailwindcss()is the Tailwind v4 Vite plugin — it scans templates and emits the utility CSS thatapp/styles/index.cssimports via@import "tailwindcss". CSS is processed throughlightningcss(css.transformer: "lightningcss"in the Vite config).
app/styles/index.css is the single CSS entry point.
It uses Tailwind v4's CSS-first config:
@import "tailwindcss";
@theme {
--color-foreground: #121212;
--color-bsky: #3992ff;
--color-favorite: #eeb004;
/* ...design tokens... */
}
@layer base {
body { color: var(--color-foreground); /* ... */ }
button { /* ... */ }
}
@utility search-icon { background-image: url("data:image/svg+xml,..."); }
@utility spinner-icon { background-image: url("data:image/svg+xml,..."); }Conventions:
- Design tokens live in
@theme. Tailwind exposes each as a CSS variable and a generated utility (--color-favorite→text-favorite,bg-favorite). Components reference tokens via utilities, never raw hex values. - Component-local utilities, not BEM-style classes. Every JSX
element styles itself inline (
className="flex h-full w-full"). No.contact-list { ... }rules in CSS. @utilityfor shared one-off backgrounds (inline SVG icons, patterns) so they're still composable with state variants likehover:ordata-[…]:.- The CSS file imports Tailwind once, at the top. Don't fragment styles into per-component CSS files — utilities go in JSX.
vite-plus is used as a task runner. Commands:
vpr dev— runsdev:convexanddev:vitein parallel after adb:resetthat wipes.convex/. Convex auto-seeds the schema and the app reseeds viaapp/data/seed.tson first server boot.vpr build— production build.vpr preview— locally serve the built Worker.vpr typecheck— runs codegen (typegen:cloudflare→wrangler types,typegen:convex→convex codegen --typecheck disable) thentsgo --noEmit. Always green this before pushing.vpr check—fmt+lint --fix+typecheck. The single quality gate to run before committing. Lint is type-aware (typeAware: true, typeCheck: true) and pulls ineslint-plugin-perfectionistandeslint-plugin-prefer-let.
Per CLAUDE.md: do not start the dev server from an agent context. The
human likely has it running already; ask them to start one if needed.
// app/router.tsx
const { DEV, SSR } = parseEnv(import.meta.env);
if (DEV && SSR) {
let { seedDatabase } = await import("./data/seed.ts");
await seedDatabase();
}Imported dynamically inside a DEV && SSR gate so the seeding code is
tree-shaken from the production bundle. seed.ts is idempotent — it inserts
only if the table is empty.
These files are generated and gitignored or excluded from review:
app/routes.gen.ts— route tree.convex/_generated/**— Convex API + dataModel types.worker-configuration.d.ts— Cloudflare bindings types.
Never edit them. If something is wrong, fix the upstream config and re-run codegen.
These mirror CLAUDE.md but with reasoning. When in doubt, follow these.
let, notconst, for everything that isn't a true module-scope constant. Top-level constants useSCREAMING_SNAKE_CASE. Lint (prefer-let/prefer-let) enforces.- One declaration per
let. .ts/.tsxextensions in imports —import/extensionsenforces.import typefor type-only imports —verbatimModuleSyntax: trueintsconfig.jsonmakes this an error otherwise.node:protocol for Node built-ins on the rare occasion you reach for one (the Worker usesnodejs_compatshims).- Self-close empty JSX elements.
- Sorted JSX props (perfectionist) — accept the lint suggestion.
- No
any. Useunknownif the type is genuinely unknown. - No
astypecasts except at validated boundaries (e.g. castingstring→Id<"contacts">immediately before handing to Convex, which revalidates). - No non-null assertions (
!). Reach forassert()from@std/assertor a discriminated union instead. - Infer types whenever possible. Don't over-annotate function args or return types if the inference is correct.
- Do not touch
document.cookie. Use TanStack Start's cookie utilities from@tanstack/react-start/server.
- Components do not call
convexQuerydirectly. They import a builder from#/data/queries.ts. This is what guarantees loader/ component query-key parity. - Server functions live in
#/data/mutations.ts. UI components import them as values to grab.url/.methodand wrap withuseServerFn. - No data fetching inside
app/components/. Components receive data via props or read from already-primed queries.
- Compile regexes at module scope, never inside hot functions
(e.g.
AT_PATTERNinconvex/contacts.ts). defaultPreload: "intent"is already on — links preload on hover. Don't add manual preloading on top of it.- Trust the React Compiler. Avoid manual
useMemo/useCallbackunless profiling proves they're needed.
- Convex queries are subscriptions. A row that exists at loader time can
be deleted before the component reads it. Re-check
if (!contact) throw notFound();inside components, not just loaders. - Optimistic updates via
.withOptimisticUpdateapply to Convex's local store. React Query's hydrated cache will reconcile when the WebSocket pushes the canonical update.
- Anything importing
@tanstack/react-startserver APIs (createServerFn,ConvexHttpClient) must only be reached from server-execution paths or throughuseServerFn. Don't import server-only modules eagerly into client-rendered components except to grab the.url/.methodmetadata —useServerFnis the safe wrapper for the callable.
- Add the table (or fields) to
convex/schema.ts. - Add Convex
query/mutationhandlers underconvex/<entity>.ts. - Add query-options builders in
app/data/queries.ts. - Add server functions in
app/data/mutations.tsif you need redirects or progressive enhancement; otherwise call the Convex mutation directly withuseMutation. - Register the route in
app/routes.tsand create the route file. - Wire the loader to
ensureQueryData(yourQuery(...)). - Read in the component via
useSuspenseQuery(yourQuery(...)). - Run
vpr checkbefore committing.
So you don't waste time looking for it:
- No auth. When you need it, reach for Clerk — it's the smoothest
fit with this stack. Use
@clerk/tanstack-react-startfor the framework integration (it wires<ClerkProvider>around the router inapp/router.tsxand exposesgetAuth()for server functions), and use Clerk's official Convex integration to forward the user's JWT to Convex soctx.auth.getUserIdentity()works inside queries and mutations. Wrap yourConvexReactClientwithConvexProviderWithClerkinstead of the plainConvexProvider, and gate server functions withawait getAuth(...)before opening theConvexHttpClient(forward the token so the Convex side can enforce auth at the function boundary, not just the UI). - No tests. Add Vitest for unit tests, Playwright for e2e. The
Convex CLI ships a
convex-testharness for backend logic. - No error boundaries beyond defaults. Add
errorComponentto routes for production. - No internationalization, accessibility audit, or perf budgets.
These are deliberately left out so the architecture is legible. Add them incrementally; none of them require breaking these patterns.
| Need | Location |
|---|---|
| Add/change a database table | convex/schema.ts |
| Add a Convex query/mutation | convex/<entity>.ts |
| Build a typed query options object | app/data/queries.ts |
| Add a server function (form post target) | app/data/mutations.ts |
| Create or rename a route | app/routes.ts + file under app/routes/ |
| Validate search params / form data / env | app/lib/schemas.ts (Valibot + @conform-to/valibot) |
| Add a design token or base style | app/styles/index.css (@theme, @layer base) |
| Compose providers around the router | app/router.tsx |
Render <html>, layout, root data |
app/root.tsx |
Set page <title> / <meta> / <link> |
Render directly in the route component (React 19 hoists) |
| Configure Vite, vite-plus tasks, lint, fmt | vite.config.ts |
| Configure the Worker | wrangler.jsonc |
| Cloudflare binding types | worker-configuration.d.ts (generated) |
- TanStack Start: https://tanstack.com/start/latest/docs/framework/react/overview
- TanStack Router loaders & search params: https://tanstack.com/router/latest/docs/framework/react/guide/data-loading
@tanstack/react-router-ssr-query: https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading#ssr-with-tanstack-query@convex-dev/react-query: https://docs.convex.dev/quickstart/react- Convex schema + validators: https://docs.convex.dev/database/schemas
- Cloudflare Vite plugin: https://developers.cloudflare.com/workers/vite-plugin/
- Valibot: https://valibot.dev — schema validation patterns used here.
@conform-to/valibot: https://conform.guide/api/valibot — form-data parsing built on Valibot schemas.- React "you might not need an effect": https://react.dev/learn/you-might-not-need-an-effect
When patterns above conflict with newer guidance from these sources, update this file and the corresponding code together. Architecture docs that drift from the codebase are worse than no docs.