Start here
The one mental shift that matters most
Next.js App Router is server-first. TanStack Start is isomorphic by default.
In Start, a normal module, component, and route loader may execute in both the server and browser environments. Database access, secrets, and filesystem work must cross an explicit server boundary.
TanStack Start is best understood as TanStack Router plus a server and build layer. The route tree remains the application contract. Start adds SSR, streaming, server functions, server routes, middleware, and deployment output.
So the question is no longer “is this a Server Component or Client Component?” The question becomes “is this code isomorphic, server-only, or client-only?”
Translation table
Next.js concepts mapped to Start
| Next.js App Router | TanStack Start | What changes |
|---|---|---|
app/page.tsx | src/routes/index.tsx | The route file exports a typed Route definition. |
app/layout.tsx | src/routes/__root.tsx | The root route owns the document shell, providers, metadata, and outlet. |
[planId] | $planId | Dynamic params use a dollar prefix and are read with Route.useParams(). |
next/link | @tanstack/react-router Link | Use typed to and params props instead of interpolated href strings. |
Server Action + "use server" | createServerFn() | The network boundary is explicit, typed, and callable from loaders or UI code. |
app/api/.../route.ts | A file route with server.handlers | Use this when the HTTP endpoint itself is the contract. |
metadata export | Route head option | Head data belongs to the route definition and can use loader data. |
middleware.ts / proxy.ts | createMiddleware() and optional src/start.ts | Request and server-function middleware are separate, composable concepts. |
| Server Component fetch | Route loader calling a server function | The loader coordinates navigation; the server function protects server-only work. |
Repository tour
What is already configured in AI Planner
vite.config.ts
Start plugin, file-route generation, Tailwind, Devtools, React, and React Compiler.
src/router.tsx
Creates a fresh router context and connects TanStack Query’s SSR dehydration and hydration.
src/routes/__root.tsx
Owns html, head, body, Scripts, Devtools, and the TanStack DB provider.
src/db/index.server.ts
A lazy, server-protected Drizzle connection. Call getDb() only from server work.
src/db/schema.ts
The persistent SQLite schema source of truth. It is intentionally empty until the domain is designed.
src/db-collections/index.ts
An empty, in-memory TanStack DB plan collection descriptor. It demonstrates the boundary without fake records.
Do not edit src/routeTree.gen.ts. The Router plugin generates it from files under src/routes. Run npm run generate-routes when you want to regenerate it explicitly.
Router-first application
Routes are typed modules, not page components
A route definition can own params, validated search state, loading, metadata, guards, error UI, and the component. This keeps navigation behavior close to the URL contract.
// src/routes/plans/$planId.tsx
import { createFileRoute, Link } from "@tanstack/react-router";
export const Route = createFileRoute("/plans/$planId")({
component: PlanPage,
});
function PlanPage() {
const { planId } = Route.useParams();
return (
<main>
<h1>Plan {planId}</h1>
<Link to="/plans/$planId" params={{ planId: "next-plan" }}>
Open another plan
</Link>
</main>
);
}
Use the URL as durable state
Search, sort, pagination, tabs, and filters often belong in validated search params rather than local React state. When a loader depends on search state, declare that dependency with loaderDeps so caching and preloading use the correct key.
Providers belong above Outlet
This project separates the root document shell from the app provider layer. RootDocument owns the HTML document. RootComponent wraps Outlet in DbProvider. That is the Start equivalent of keeping global providers in a Next.js root layout.
Execution boundaries
Server functions replace most Server Actions
Use a server function for typed app-to-server RPC. Validate all input at the boundary. Use a server route when a webhook, third-party client, file response, or public API needs a normal HTTP contract.
// src/features/plans/plans.functions.ts
import { createServerFn } from "@tanstack/react-start";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { getDb } from "#/db/index.server";
import { plans } from "#/db/schema";
const PlanInput = z.object({ id: z.string().uuid() });
export const getPlan = createServerFn({ method: "GET" })
.validator(PlanInput)
.handler(async ({ data }) => {
return getDb().query.plans.findFirst({
where: eq(plans.id, data.id),
});
});
// src/routes/plans/$planId.tsx
export const Route = createFileRoute("/plans/$planId")({
loader: ({ params }) => getPlan({ data: { id: params.planId } }),
component: PlanPage,
});
function PlanPage() {
const plan = Route.useLoaderData();
return <h1>{plan?.title ?? "Plan not found"}</h1>;
}
A route guard is not API security. A server function is an endpoint and can be called independently of the screen that uses it. Authenticate and authorize inside server middleware or the handler itself.
Use filename boundaries as guardrails
*.server.tsis protected from client imports.*.client.tsis protected from server imports.- Unmarked modules are isomorphic and should contain portable code.
- Read secrets inside handlers or other per-request server callbacks, not in shared module initialization.
Pick one owner per concern
Router loaders, Query, and DB solve different problems
| Tool | Use it for | Do not reach for it when |
|---|---|---|
| Router loader | Data required to enter a route, URL-dependent data, redirects, and simple route-level caching. | The same server data is shared and mutated across many unrelated routes. |
| TanStack Query | Async server-state caching, deduplication, retries, background refresh, and mutations. | You need relational live queries and collection-level optimistic transactions. |
| TanStack DB | Reactive typed collections, client-side joins and filters, and instant optimistic writes synced through an adapter. | You only need one straightforward fetch on one page. |
| Drizzle | Typed SQL schema, queries, and migrations on the server. | You need client cache or UI reactivity; Drizzle never runs in the browser here. |
This project sets defaultPreloadStaleTime: 0 because TanStack Query is the external cache. The Router still coordinates loading, while Query decides freshness.
Persistent data
Drizzle owns the SQLite truth
Drizzle is the server-side ORM and schema tool. It is not a client state manager. In this starter, drizzle.config.ts points Drizzle Kit at src/db/schema.ts and the DATABASE_URL SQLite file.
1. Define a real table
// src/db/schema.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const plans = sqliteTable("plans", {
id: text().primaryKey(),
title: text().notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});
2. Choose a migration workflow
Fast prototype
npm run db:push compares the TypeScript schema with the database and applies changes directly.
Reviewable history
npm run db:generate creates migrations; inspect them, then run npm run db:migrate.
For a real application, generated and reviewed migrations provide a durable history. push is convenient while the schema is disposable.
3. Keep it behind a Start server boundary
Call getDb() inside a server function or server-route handler. The lazy getter keeps the SQLite driver and environment variable access out of the browser bundle and defers connection work until the server actually needs it.
4. Know the commands
npm run db:generate # schema diff → migration files
npm run db:migrate # apply generated migrations
npm run db:push # direct schema sync; useful for early prototypes
npm run db:pull # introspect an existing database
npm run db:studio # inspect and edit data in Drizzle Studio
Reactive client data
TanStack DB is a client data layer, not another SQL database
TanStack DB organizes records into typed collections. Components query those collections with reactive relational queries. Writes apply optimistically, then an adapter persists them and replaces optimistic state with confirmed server state.
Your current collection is intentionally local-only. localOnlyCollectionOptions stores data in memory. Refreshing the page loses it. This is useful for learning collection APIs, temporary drafts, or session-only UI state—not durable plans.
Read the configured collection
import { useDbClient, useLiveQuery } from "@tanstack/react-db";
import { plansCollection } from "#/db-collections";
function Plans() {
const plans = useDbClient().collection(plansCollection);
const { data, isLoading } = useLiveQuery({
query: (q) => q.from({ plan: plansCollection }),
});
if (isLoading) return <p>Loading…</p>;
return (
<>
<button
onClick={() =>
plans.insert({ id: crypto.randomUUID(), title: "New plan" })
}
>
Add plan
</button>
{data.map((plan) => <p key={plan.id}>{plan.title}</p>)}
</>
);
}
Move from local-only to a backend
When the server contract exists, replace the local adapter with a Query Collection descriptor. The DbClient already receives this project’s QueryClient, so the adapter can reuse it.
import { QueryClient } from "@tanstack/react-query";
import { collectionOptions } from "@tanstack/react-db";
import { queryCollectionOptions } from "@tanstack/query-db-collection";
import { listPlans, savePlan } from "#/features/plans/plans.functions";
export const plansCollection = collectionOptions("plans", (client) =>
queryCollectionOptions({
id: "plans",
queryKey: ["plans"],
queryClient: client.requireDependency<QueryClient>("queryClient"),
queryFn: () => listPlans(),
getKey: (plan) => plan.id,
schema: PlanSchema,
onInsert: async ({ transaction }) => {
await savePlan({ data: transaction.mutations[0].modified });
},
}),
);
The normal flow is: optimistic collection write → adapter mutation handler → Start server function or server route → Drizzle → SQLite → collection refetch/sync confirmation.
About SSR
Current TanStack DB supports request-scoped SSR and hydration through DbClient, stable collectionOptions descriptors, dehydration, and DbProvider. This starter has the provider and descriptor foundation but does not yet preload or dehydrate collection data. Add that only when a real route needs an SSR snapshot; otherwise let the collection start in the browser.
One coherent stack
How the pieces fit together
Durable truth
SQLite rows and migrations, accessed through Drizzle on the server.
Network boundary
Validated, authorized Start server functions or explicit server routes.
Remote cache
TanStack Query, when server data needs request caching and background refresh.
Reactive projection
TanStack DB, when client views need live queries, joins, or optimistic collection writes.
Recommendation: begin with Route loader → server function → Drizzle. Add TanStack Query when caching becomes shared or complex. Add a server-backed TanStack DB collection when you need reactive local views or optimistic workflows. You do not need every layer for every feature.
Suggested learning order
Build the first real feature in this sequence
- Model one table. Add a small
planstable tosrc/db/schema.ts. - Create and inspect a migration. Run
npm run db:generate, read the SQL, then migrate. - Write one read server function. Validate input and call
getDb(). - Load it from a route. Put the server function in a route loader and read it with
Route.useLoaderData(). - Add one POST server function. Persist a record, then invalidate the Router or the relevant Query cache.
- Only then decide on TanStack DB. If the UI needs instant optimistic writes, several filtered views, or joins, switch
plansCollectionto a server-backed adapter.
Definition of done for each slice
- Direct URL load works through SSR.
- Client navigation loads the same result.
- Server-only imports are absent from the client bundle.
- Mutation input is validated and authorization is enforced at the endpoint.
- The UI refreshes from confirmed server state after a write.
npm run checkandnpm run buildpass.
Save yourself an afternoon
Common Next.js carry-over mistakes
Putting Drizzle directly in a loader
createServerFn; do not assume the loader itself is server-only.Adding "use client" or "use server"
createServerFn, and *.server.ts/*.client.ts import protection.Editing the generated route tree
npm run generate-routes update routeTree.gen.ts.Interpolating dynamic links
<Link to="/plans/$planId" params={{ planId }} />. This preserves route type checking and autocomplete.Treating local-only TanStack DB as persistence
Using Router, Query, and DB caches for the same job
Copying old TanStack DB examples
DbClient, DbProvider, collectionOptions, and object-form useLiveQuery APIs. Older singleton collections still work, but descriptors are the SSR-safe and 1.0-ready direction.Primary references
Official documentation used for this guide
- TanStack Start: Execution Model
- TanStack Start: Server Functions
- TanStack Start: Middleware
- TanStack Start comparison with Next.js
- TanStack Router: Data Loading
- TanStack Router: Query Integration
- TanStack DB: Quick Start
- TanStack DB: React Adapter
- TanStack DB: SSR and Hydration
- TanStack DB: Query Collection
- Drizzle ORM: Schema Declaration
- Drizzle ORM: Migrations
- Drizzle ORM: SQLite
TanStack Start and TanStack DB move quickly. This guide is aligned to the versions installed in AI Planner on August 21, 2026. When an API differs, check the installed package version before copying a newer or older snippet.