Project-specific learning guide

TanStack Start for a Next.js developer

A practical map of the concepts that change, the pieces already configured in AI Planner, and how TanStack Start, Drizzle, TanStack Query, and TanStack DB fit together without becoming four competing sources of truth.

Updated August 21, 2026 TanStack Start 1.168 TanStack DB core 0.8 Drizzle ORM 0.45 Status: starter guide

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 RouterTanStack StartWhat changes
app/page.tsxsrc/routes/index.tsxThe route file exports a typed Route definition.
app/layout.tsxsrc/routes/__root.tsxThe root route owns the document shell, providers, metadata, and outlet.
[planId]$planIdDynamic params use a dollar prefix and are read with Route.useParams().
next/link@tanstack/react-router LinkUse 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.tsA file route with server.handlersUse this when the HTTP endpoint itself is the contract.
metadata exportRoute head optionHead data belongs to the route definition and can use loader data.
middleware.ts / proxy.tscreateMiddleware() and optional src/start.tsRequest and server-function middleware are separate, composable concepts.
Server Component fetchRoute loader calling a server functionThe 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.ts is protected from client imports.
  • *.client.ts is 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

ToolUse it forDo not reach for it when
Router loaderData 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 QueryAsync server-state caching, deduplication, retries, background refresh, and mutations.You need relational live queries and collection-level optimistic transactions.
TanStack DBReactive typed collections, client-side joins and filters, and instant optimistic writes synced through an adapter.You only need one straightforward fetch on one page.
DrizzleTyped 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

Route or component
TanStack DB / Query
Server function or route
Drizzle ORM
SQLite

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

  1. Model one table. Add a small plans table to src/db/schema.ts.
  2. Create and inspect a migration. Run npm run db:generate, read the SQL, then migrate.
  3. Write one read server function. Validate input and call getDb().
  4. Load it from a route. Put the server function in a route loader and read it with Route.useLoaderData().
  5. Add one POST server function. Persist a record, then invalidate the Router or the relevant Query cache.
  6. Only then decide on TanStack DB. If the UI needs instant optimistic writes, several filtered views, or joins, switch plansCollection to 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 check and npm run build pass.

Save yourself an afternoon

Common Next.js carry-over mistakes

Putting Drizzle directly in a loader
A loader runs during SSR and client navigation. Wrap database work in createServerFn; do not assume the loader itself is server-only.
Adding "use client" or "use server"
Those directives are not the Start boundary model. Use normal isomorphic code, createServerFn, and *.server.ts/*.client.ts import protection.
Editing the generated route tree
Edit route files. Let the Vite plugin or npm run generate-routes update routeTree.gen.ts.
Interpolating dynamic links
Prefer <Link to="/plans/$planId" params={{ planId }} />. This preserves route type checking and autocomplete.
Treating local-only TanStack DB as persistence
It is memory only. Use a persistence or sync adapter when data must survive refreshes or be shared with the server.
Using Router, Query, and DB caches for the same job
Choose an owner. Let the Router coordinate route entry, Query own asynchronous cache freshness, and DB own reactive collections only where those capabilities are needed.
Copying old TanStack DB examples
Prefer the current 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 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.