Guides

TanStack Start

TanStack Start is a full-stack React framework built on top of TanStack Router. It gives you file-based routing, nested layouts, SSR, server functions and type-safe routing in one setup.

7 min read

Packages

TanStack Start relies on three core TanStack packages that work together to provide file-based routing, nested layouts, SSR and full-stack app features.

  • @tanstack/react-start: The full-stack framework layer. Handles SSR, server functions, document rendering and app entry setup.
  • @tanstack/react-router: Powers all routing - file-based routes, nested layouts, loaders, path params, search params and navigation.
  • @tanstack/router-plugin: Build-time Vite plugin that scans src/routes/ and generates the routeTree.gen.ts type file.

Project Structure

The src/routes directory defines the app’s route tree, use file based routing

src/
├── components               # Reusable React components
├── hooks                    # Custom React hooks
├── lib                      # Utility functions, DB code and business logic
├── routes/
│   ├── posts/
│   │   ├── route.tsx        # Directory route for /posts; parent layout for child routes
│   │   ├── index.tsx        # Index route for /posts
│   │   └── $slug.tsx        # Dynamic route for /posts/:slug
│   ├── _account/
│   │   ├── route.tsx        # Pathless layout route; wraps children without adding /account
│   │   ├── orders.tsx       # Route for /orders
│   │   └── profile.tsx      # Route for /profile
│   ├── __root.tsx           # Root route; wraps the entire app
│   ├── index.tsx            # Index route for /
│   ├── about.tsx            # Route for /about
│   ├── robots[.]txt.ts      # File route for /robots.txt
│   └── $.tsx                # Catch-all / splat route
├── global.css               # Global styles imported by __root.tsx, e.g. shadcn tokens
├── router.tsx               # Router instance and app router setup
└── routeTree.gen.ts         # Generated route tree; do not edit manually
APIUse for
<Link>Normal in-app navigation; preferred for anything clickable
useNavigate()Imperative navigation after a side effect
<Navigate>Immediate client-side redirect on render
router.navigate()Imperative navigation outside React components
useMatchRoute() / <MatchRoute>Checking whether a route is currently active or pending

Use <Link> for most navigation. It supports route params, search params, active state and preloading.

import { Link } from "@tanstack/react-router";

<Link
  to="/posts/$slug"
  params={{ slug: "my-post" }}
  search={{ page: 2 }} // (optional) query string values like ?page=2
  preload="intent" // (optional) preload route data/code on hover or touch
  activeProps={{ className: "font-semibold" }} // (optional) props applied when the link is active
  activeOptions={{ exact: true }} // (optional) only mark active on an exact path match
>
  My Link Text
</Link>;

useNavigate()

Use for redirects after actions (like mutations).

import { useNavigate } from "@tanstack/react-router";

const navigate = useNavigate();

navigate({
  to: "/posts/$slug",
  params: { slug: "new-post" },
  search: { page: 2 }, // (optional) query string values like ?page=2
  hash: "comments", // (optional) navigate to a hash fragment like #comments
});

Route Matching

Use route matching to check whether a route is currently active or pending.

<MatchRoute>

Use <MatchRoute> for conditional rendering

import { MatchRoute } from "@tanstack/react-router";

function Nav() {
  return <MatchRoute to="/posts">Posts page is active</MatchRoute>;
}

useMatchRoute()

Returns a function that checks whether a route matches the current location.

import { useMatchRoute } from "@tanstack/react-router";

function Sidebar() {
  const matchRoute = useMatchRoute();
  const isPostsActive = !!matchRoute({
    to: "/posts",
  });

  return <div>{isPostsActive ? "Posts is active" : "Posts is inactive"}</div>;
}

Params

Path Params

Use path params for dynamic URL segments like /posts/:slug. Path params can be accessed in a route loader or inside the route component with Route.useParams().

import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/posts/$slug")({
  component: PostPage,
  // Access path params inside the loader
  loader: async ({ params }) => {
    return fetchPost(params.slug);
  },
});

function PostPage() {
  // Access path params inside the component
  const { slug } = Route.useParams();

  return <div>Post: {slug}</div>;
}

Search Params

Use search params for query strings like ?page=2&sort=newest. Define validateSearch on the route to parse and type URL values, then read them with Route.useSearch(). Update them with <Link search={...}> or navigate({ search: ... }). You can pass a Zod schema directly to validateSearch since Zod schemas expose a .parse method.

src/routes/posts/index.tsx
import { createFileRoute, Link } from "@tanstack/react-router";
import { z } from "zod";

const searchSchema = z.object({
  page: z.number().catch(1),
  sort: z.enum(["newest", "oldest"]).catch("newest"),
});

export const Route = createFileRoute("/posts/")({
  component: PostsPage,
  validateSearch: searchSchema,
  loaderDeps: ({ search }) => search, // re-run loader when search params change
  loader: async ({ deps: { page, sort } }) => ({
    posts: await fetchPosts({ page, sort }),
  }),
});

function PostsPage() {
  const { page, sort } = Route.useSearch();
  const { posts } = Route.useLoader();

  return (
    <div>
      {posts.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
      {/* Use the function form to avoid clobbering other params */}
      <Link from={Route.fullPath} search={(prev) => ({ ...prev, page: prev.page + 1 })}>
        Next page
      </Link>
    </div>
  );
}

Zod Adapter

If you need search params to be optional when navigating (i.e. <Link to="/posts/"> without a search prop) while still having correct types and fallback values, the plain Zod approach won't get you there cleanly:

  • .default() — makes navigation require search props
  • .catch() — makes navigation optional but causes type loss (unknown)

The @tanstack/zod-adapter package solves this with a fallback helper that retains types while allowing optional navigation:

import { fallback, zodValidator } from "@tanstack/zod-adapter";

const searchSchema = z.object({
  page: fallback(z.number(), 1).default(1),
  sort: fallback(z.enum(["newest", "oldest"]), "newest").default("newest"),
});

export const Route = createFileRoute("/posts/")({
  validateSearch: zodValidator(searchSchema),
  // ...
});

For most apps the plain Zod approach is fine. Reach for the adapter when you have routes where search params should always be optional.

Data Loading (loaders)

Use loaders to fetch data for route components.

src/routes/invoice/$id.tsx
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/invoice/$id")({
  component: InvoicePage,
  // Loaders can access path params and search params
  loader: async ({ params }) => ({
    invoice: await fetchInvoice(params.id),
    user: await fetchCurrentUser(),
  }),
});

function InvoicePage() {
  // Access loader data with Route.useLoader()
  const { invoice, user } = Route.useLoader();
  return (
    <div>
      Invoice for ${invoice.amount} by {user.name}
    </div>
  );
}

Server Functions

Define server functions in the src/lib directory to handle server-side logic like database reads/writes, authentication or third-party API calls.

src/lib/invoices.ts
import { createServerFn } from "@tanstack/react-start";

// GET request (default)
export const listInvoices = createServerFn().handler(async () => {
  return await db.invoice.findMany();
});

// POST request
export const updateInvoice = createServerFn({ method: "POST" })
  // Use Zod for input validation
  .inputValidator(z.object({ id: z.number(), amount: z.number() }))
  .handler(async ({ data }) => {
    const { id, amount } = data;
    return await db.invoice.update({ where: { id }, data: { amount } });
  });

Call server functions from the loader or using the useServerFn hook inside route components.

src/routes/invoices.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";

import { listInvoices, updateInvoice } from "@/lib/invoices";

export const Route = createFileRoute("/invoices")({
  component: InvoicesPage,
  loader: async () => ({
    // Call within a loader
    invoices: await listInvoices(),
  }),
});

function InvoicesPage() {
  const router = useRouter();
  const { invoices } = Route.useLoader();
  // Call with useServerFn inside a component
  const updateInvoiceFn = useServerFn(updateInvoice);

  const handleUpdate = async (id: number) => {
    await updateInvoiceFn({ id, amount: 100 });
    await router.invalidate(); // re-run loaders to reflect updated data
  };

  return (
    <div>
      {invoices.map((invoice) => (
        <div key={invoice.id}>
          Invoice #{invoice.id}: ${invoice.amount}
          <button onClick={() => handleUpdate(invoice.id)}>Update</button>
        </div>
      ))}
    </div>
  );
}

Static Server Functions

Static Server Functions are executed at build time, which is useful for Static Site Generation (SSG).

bun add -D @tanstack/start-static-server-functions
src/lib/posts.ts
import { createServerFn } from "@tanstack/react-start";
import { staticFunctionMiddleware } from "@tanstack/start-static-server-functions";

const listPosts = createServerFn({ method: "GET" })
  .middleware([staticFunctionMiddleware])
  .handler(async () => {
    return db.post.findMany();
  });

Server Routes

Use server routes to handle HTTP requests directly from a route file. Useful for webhooks, health checks, or any endpoint that doesn't need a UI.

src/routes/health.ts
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/health")({
  server: {
    handlers: {
      GET: async ({ request }) => {
        return new Response("OK");
      },
    },
  },
});