Skip to content

Fix: TanStack Start Not Working — Server Functions Failing, Routes Not Loading, or SSR Errors

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix TanStack Start issues — project setup, file-based routing, server functions with createServerFn, data loading, middleware, SSR hydration, and deployment configuration.

The Problem

A TanStack Start route renders on the server but hydration fails:

Error: Hydration mismatch — server rendered HTML doesn't match client

Or server functions return undefined:

const serverFn = createServerFn('GET', async () => {
  return { users: await db.query.users.findMany() };
});
// Returns undefined on the client

Or the dev server crashes on startup:

Error: Cannot find module 'vinxi' — or —
Error: Route tree is empty

Why This Happens

TanStack Start is a full-stack React framework built on TanStack Router and Vinxi. It’s newer than Next.js and has a different architecture:

  • TanStack Start uses Vinxi as its build system — Vinxi orchestrates the server, client, and SSR bundles. If Vinxi isn’t properly configured or installed, the dev server can’t start.
  • Server functions use createServerFn — these are RPC-like functions that run on the server and are callable from the client. They must be defined in files that the server bundle includes. Using them in client-only files causes import errors.
  • Routing is powered by TanStack Router — the same file-based routing from @tanstack/react-router applies. The route tree must be generated before routes work.
  • SSR is on by default — every route renders on the server first. Client-only code (browser APIs, window, document) causes hydration mismatches if not guarded.

TanStack Start’s design is a deliberate counter to Next.js’s App Router. Next.js bakes its router, server actions, and bundler into one tightly-coupled framework. TanStack Start instead composes existing pieces: TanStack Router (which has been stable since 2024) for routing and type-safety, Vinxi for the multi-bundle build orchestration, and Nitro (the server runtime used by Nuxt and SolidStart) for the actual server. That composability is the framework’s headline feature, but it also means errors can originate in any of those layers. A “Cannot find module” almost always comes from Vinxi failing to resolve a workspace. An “empty route tree” comes from TanStack Router’s codegen step not running. A “function not found” at runtime comes from createServerFn being imported in a client-only file.

The route tree is the second concept that tends to surprise newcomers. Unlike Expo Router or Next.js, where the file tree directly drives runtime routing, TanStack Start runs a codegen step that emits routeTree.gen.ts from your app/routes/ directory. The runtime imports that generated file. If you add a new route while the dev server is off, or if the codegen step fails because of a syntax error in an unrelated route file, the new route silently doesn’t exist. The fix is usually to run npm run dev (which triggers codegen on file changes) and watch the terminal for codegen warnings.

TanStack Start Version History

TanStack Start is still in beta, so the timeline is short but consequential:

  • TanStack Router 1.0 (early 2024) — the routing foundation went stable first. Locked in createFileRoute, the route tree codegen, search-param validation with Zod or similar, and the strongly-typed Link/useNavigate API. Any TanStack Start docs assume Router 1.0+.
  • TanStack Start alpha (mid 2024) — first public previews. Introduced createServerFn, the 'use server' and 'use client' boundaries, and the Vinxi-based dev server. APIs churned weekly — anything from this period is unreliable.
  • TanStack Start beta 1 (late 2024) — stabilized the createServerFn(method, handler) signature, added middleware support via createMiddleware, and introduced deployment presets matching Nitro’s catalog (Vercel, Netlify, Cloudflare Pages, Node).
  • TanStack Start beta 2+ (2024 — 2026) — added server streaming, refined the SSR hydration model, and started prototyping React Server Components support. The RSC story is on the roadmap but not generally available — assume client + server functions for now.

Because the framework is still pre-1.0, breaking changes between betas are common. Tutorials that pre-date the current beta will mix old API shapes (server$, separate useServerFn hook, different middleware signature). Always check the version in package.json and follow docs for that exact release. Pin @tanstack/start, @tanstack/react-router, and vinxi to compatible ranges and update them as a set, not individually.

One nuance versus Next.js is worth calling out: Next.js’s App Router treats server actions as part of the framework runtime, with bundler magic that’s largely invisible. TanStack Start’s createServerFn is more explicit — every server function has a method (GET, POST) and an exported reference, and the bundler treats them as RPC endpoints. That explicitness makes errors easier to trace (you can see exactly which function is being called) but also means there’s no implicit “form action” wiring; you call server functions from event handlers or loaders, not via <form action={...}> like in Next or SolidStart.

Fix 1: Project Setup

npm create @tanstack/start@latest my-app
cd my-app
npm install
npm run dev
// app.config.ts — TanStack Start configuration
import { defineConfig } from '@tanstack/start/config';

export default defineConfig({
  server: {
    preset: 'node-server',  // 'node-server' | 'vercel' | 'netlify' | 'cloudflare-pages'
  },
});
// app/router.tsx — router configuration
import { createRouter as createTanStackRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';

export function createRouter() {
  return createTanStackRouter({
    routeTree,
    defaultPreload: 'intent',
  });
}

declare module '@tanstack/react-router' {
  interface Register {
    router: ReturnType<typeof createRouter>;
  }
}
// app/routes/__root.tsx — root layout
import { createRootRoute, Outlet, ScrollRestoration } from '@tanstack/react-router';
import { Meta, Scripts } from '@tanstack/start';

export const Route = createRootRoute({
  component: RootComponent,
  head: () => ({
    meta: [
      { charSet: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { title: 'My App' },
    ],
  }),
});

function RootComponent() {
  return (
    <html lang="en">
      <head>
        <Meta />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

Fix 2: Server Functions

// app/server/db.ts — server-only code
import { createServerFn } from '@tanstack/start';

// GET server function — fetches data
export const getUsers = createServerFn('GET', async () => {
  const users = await db.query.users.findMany({
    orderBy: desc(users.createdAt),
    limit: 50,
  });
  return users;
});

// POST server function — mutations
export const createUser = createServerFn('POST', async (input: { name: string; email: string }) => {
  const [user] = await db.insert(users).values(input).returning();
  return user;
});

// Server function with validation
import { z } from 'zod';

const updateSchema = z.object({
  id: z.string(),
  name: z.string().min(1),
  email: z.string().email(),
});

export const updateUser = createServerFn('POST', async (rawInput: unknown) => {
  const input = updateSchema.parse(rawInput);
  const [user] = await db.update(users).set(input).where(eq(users.id, input.id)).returning();
  return user;
});

// Server function with request context
export const getCurrentUser = createServerFn('GET', async (_, ctx) => {
  const token = ctx.request.headers.get('authorization')?.replace('Bearer ', '');
  if (!token) throw new Error('Unauthorized');

  const user = await verifyTokenAndGetUser(token);
  return user;
});

Fix 3: Route Data Loading

// app/routes/posts.tsx — route with loader
import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/start';

const getPosts = createServerFn('GET', async () => {
  return db.query.posts.findMany({
    where: eq(posts.published, true),
    orderBy: desc(posts.createdAt),
  });
});

export const Route = createFileRoute('/posts')({
  loader: async () => {
    const posts = await getPosts();
    return { posts };
  },
  component: PostsPage,
});

function PostsPage() {
  const { posts } = Route.useLoaderData();

  return (
    <div>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <Link to="/posts/$postId" params={{ postId: post.id }}>
              {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

// app/routes/posts/$postId.tsx — dynamic route
const getPost = createServerFn('GET', async (postId: string) => {
  const post = await db.query.posts.findFirst({ where: eq(posts.id, postId) });
  if (!post) throw notFound();
  return post;
});

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await getPost(params.postId);
    return { post };
  },
  component: PostPage,
  notFoundComponent: () => <div>Post not found</div>,
});

function PostPage() {
  const { post } = Route.useLoaderData();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

Fix 4: Forms and Mutations

// app/routes/posts.new.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/start';
import { useState } from 'react';

const createPost = createServerFn('POST', async (input: { title: string; body: string }) => {
  const [post] = await db.insert(posts).values({
    ...input,
    published: false,
    authorId: 'current-user',
  }).returning();
  return post;
});

export const Route = createFileRoute('/posts/new')({
  component: NewPostPage,
});

function NewPostPage() {
  const navigate = useNavigate();
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setLoading(true);
    setError('');

    const formData = new FormData(e.currentTarget);
    const title = formData.get('title') as string;
    const body = formData.get('body') as string;

    try {
      const post = await createPost({ title, body });
      navigate({ to: '/posts/$postId', params: { postId: post.id } });
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to create post');
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      <h1>New Post</h1>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <form onSubmit={handleSubmit}>
        <input name="title" placeholder="Title" required />
        <textarea name="body" placeholder="Write your post..." required />
        <button type="submit" disabled={loading}>
          {loading ? 'Creating...' : 'Create Post'}
        </button>
      </form>
    </div>
  );
}

Fix 5: Middleware and Authentication

// app/middleware.ts — server middleware
import { createMiddleware } from '@tanstack/start';

export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
  const token = request.headers.get('authorization')?.replace('Bearer ', '');

  let user = null;
  if (token) {
    try {
      user = await verifyToken(token);
    } catch {
      // Invalid token — continue as unauthenticated
    }
  }

  return next({ context: { user } });
});

// Use in server functions
export const getProfile = createServerFn('GET', async (_, ctx) => {
  // ctx contains middleware context
  if (!ctx.context.user) {
    throw new Error('Unauthorized');
  }
  return ctx.context.user;
}).middleware([authMiddleware]);

// Protected route
export const Route = createFileRoute('/dashboard')({
  beforeLoad: async ({ context }) => {
    const user = await getProfile();
    if (!user) throw redirect({ to: '/login' });
    return { user };
  },
  component: DashboardPage,
});

Fix 6: Deployment

// app.config.ts — deployment presets

// Vercel
export default defineConfig({
  server: { preset: 'vercel' },
});

// Netlify
export default defineConfig({
  server: { preset: 'netlify' },
});

// Cloudflare Pages
export default defineConfig({
  server: { preset: 'cloudflare-pages' },
});

// Node.js server
export default defineConfig({
  server: { preset: 'node-server' },
});
# Build
npm run build

# Start production server (node-server preset)
node .output/server/index.mjs

Still Not Working?

“Cannot find module vinxi” — run npm install to ensure all dependencies are installed. TanStack Start depends on Vinxi internally. If the error persists, delete node_modules and package-lock.json, then reinstall.

Route tree is empty — route files must be in app/routes/ and export const Route = createFileRoute(...). Run the dev server to auto-generate routeTree.gen.ts. Check that filenames follow the convention: index.tsx, posts.tsx, posts.$postId.tsx.

Server function returns undefined — ensure the function returns a value. createServerFn('GET', async () => { db.query... }) without return produces undefined. Also check the function is imported correctly — server functions must be called, not passed as references.

Hydration mismatch — code that accesses window, document, or localStorage runs on the server during SSR. Guard browser-only code with typeof window !== 'undefined' checks or use useEffect for client-only logic.

Codegen produces stale routes after deletesrouteTree.gen.ts accumulates routes during the dev session. If you delete a route file, the generated tree sometimes keeps the stale entry, causing TypeScript errors and 404s. Stop the dev server, delete routeTree.gen.ts, then restart — codegen rebuilds it from scratch.

createServerFn import error in a .client.tsx file — server functions can be called from the client, but the file that defines them must be reachable by the server bundle. If you put createServerFn inside a file marked .client.tsx or under a client-only conditional, Vinxi won’t include it in the server bundle. Move the definition to a regular .ts/.tsx file.

Middleware doesn’t run for a server function — middleware is attached per-server-function with .middleware([authMiddleware]). There’s no global “all functions use auth” config in current betas. If a function bypasses auth, check that the .middleware(...) call is on the server function itself, not on the calling route.

For related framework issues, see Fix: TanStack Router Not Working and Fix: SolidStart Not Working. For server-state management inside Start routes, see Fix: TanStack Query Not Working. For Vinxi/Vite layer issues, see Fix: Vite Failed to Resolve Import.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles