Bitdoze Logo

Getting Started with TanStack Start and Convex (2026 Guide)

Step-by-step guide to getting started with TanStack Start and Convex: scaffold a full-stack React SaaS and wire up the realtime Convex backend in 2026.

DragosDragos31 min read
Getting Started with TanStack Start and Convex (2026 Guide)

TanStack Start

Part 1 of 1

1
Getting Started with TanStack Start and Convex (2026 Guide)

This is the first article in our series on building a modern SaaS application with TanStack Start and Convex. TanStack Start is now at v1 Release Candidate, feature-complete with a stable API, and the tooling has changed significantly since the original 2025 version of this guide. By the end of this article you’ll have a running full-stack React app with SSR, file-based routing, and a realtime Convex backend.

What changed since 2025

The TanStack Start ecosystem went through several breaking changes:

  • Project structure: app/ directory moved to src/
  • Dev server: vinxi replaced by a native Vite plugin (built on Nitro)
  • Package rename: @tanstack/start is now @tanstack/react-start
  • Head management: Meta from @tanstack/start replaced by HeadContent from @tanstack/react-router

This guide reflects the current (2026) state of the framework.

What is TanStack Start?

TanStack Start is a full-stack React framework built on top of TanStack Router. It comes from the TanStack team (the same people behind TanStack Query, Router, Table, and other widely-used libraries). Key features:

  • File-based routing: similar to Next.js or Remix, routes map to files in src/routes/.
  • Server-side rendering (SSR) with client-side hydration.
  • Integrated data fetching via TanStack Query hooks.
  • Vite tooling (or Rsbuild 2 as an alternative) for fast dev and builds.
  • Nitro-powered server output: deploy to any Node.js host, serverless platform, or VPS.

TanStack Start reached v1.0 Release Candidate in September 2025 and ships 1.x npm versions. The API is considered stable. If you want to see how TanStack Start compares to Next.js and Astro, we have a dedicated comparison.

What is Convex?

Convex is a realtime backend-as-a-service platform. It handles your database, serverless functions, and client sync so you can focus on frontend code. You can also build a real-time app with Convex using other frameworks.

  • Realtime database: automatically syncs data changes to connected clients
  • Serverless functions: write queries (read) and mutations (write) in TypeScript, hosted by Convex
  • End-to-end type safety: shared types between frontend and backend
  • Web dashboard: manage data, view logs, and debug from docs.convex.dev/dashboard
  • File storage, scheduled tasks, and vector search built in

Convex integrates with TanStack Start via the @convex-dev/react-query package, which lets you fetch and mutate data using standard TanStack Query hooks.

Prerequisites

Before you start, make sure you have:

  • Node.js >= 20.19 (Node 22 LTS recommended). The @tanstack/react-router package requires this as a minimum. If you’re on Node 18, you’ll hit engine errors on install.
  • npm (comes with Node)
  • A terminal and code editor
  • A Convex account is optional for local development — Convex runs locally without one. You can link to a cloud account later with npx convex login.

No Docker, no VPS, no DNS needed for this tutorial. Everything runs locally.

Installation: scaffold with Convex

The fastest way to get started is the Convex template, which scaffolds a project with TanStack Start and Convex pre-wired:

Verify the scaffold worked:

cat package.json | grep -E "tanstack|convex"

You should see @tanstack/react-start, @tanstack/react-router, @tanstack/react-query, convex, and @convex-dev/react-query in the output.

Failure mode: If npm create convex fails, check your Node version (node -v) and npm registry connectivity (npm ping).

Starting the development environment

Run the dev server:

npm run dev

Under the hood, this executes convex dev --start 'vite dev' — the Convex CLI drives Vite directly. On first run, Convex will prompt you to name your project and bootstrap a local backend:

? Choose a name: my-saas-app
✔ Downloaded Convex backend binary
✔ Started running a deployment locally at http://127.0.0.1:3210
    name as CONVEX_DEPLOYMENT to .env.local
    URL as VITE_CONVEX_URL to .env.local

  ➜ Local:    http://localhost:3000/
✔ Convex functions ready! (839.54ms)

Verify it worked

  1. Open http://localhost:3000 in your browser — you should see the default page.
  2. Confirm “Convex functions ready” appears in your terminal.
  3. Check that .env.local contains VITE_CONVEX_URL:
cat .env.local | grep VITE_CONVEX_URL

What’s happening:

  • Convex initialization: convex dev sets up a local backend, saves VITE_CONVEX_URL and CONVEX_DEPLOYMENT to .env.local. These connect your frontend to the Convex backend.
  • Vite dev server: Your app runs at http://localhost:3000.
  • Convex dashboard: Run npx convex dashboard to open the web UI for managing data, viewing logs, and testing functions.

Tip: Run npx convex login to link your local deployment to a Convex account for cloud syncing and deployment.

Failure modes:

  • Missing VITE_CONVEX_URL: If the frontend can’t connect, restart npm run dev after the first convex dev bootstraps the env file.
  • Port 3000 in use: Vite auto-picks another port (check terminal output), or set server.port in vite.config.ts.

Understanding the project structure

The scaffold creates this layout (note: src/, not app/):

.
├── src/
│   ├── routes/               # Page routes
│   │   ├── __root.tsx        # Root layout (HTML structure, global providers)
│   │   └── index.tsx         # Homepage component ('/')
│   ├── router.tsx            # Router setup (integrates Convex + Query)
│   ├── routeTree.gen.ts      # Auto-generated route tree
│   ├── start.ts              # App entry point (replaces old client.tsx + ssr.tsx)
│   └── styles/
│       └── app.css           # Global styles
├── convex/                   # Backend logic
│   ├── schema.ts             # Database schema (defines tables)
│   ├── _generated/           # Auto-generated types and API
│   └── ...                   # Your queries and mutations
├── public/                   # Static assets
├── .env.local                # Environment variables (VITE_CONVEX_URL)
├── vite.config.ts            # Vite configuration
├── convex.config.ts          # Convex config
├── package.json
└── tsconfig.json

Key files:

  • src/router.tsx — creates the TanStack Router instance and wires up Convex + Query client. This is the integration point.
  • src/routes/__root.tsx — defines the root layout (<html>, <head>, <body>) and renders <Outlet /> for child routes.
  • src/routes/index.tsx — the homepage, uses createFileRoute('/').
  • src/start.ts — the single entry point that replaces the old client.tsx + ssr.tsx.
  • convex/schema.ts — defines your database tables. See the Convex schema docs.
  • .env.local — stores VITE_CONVEX_URL. Never commit this file to Git.

If you see an app/ directory

If your scaffold has app/routes/, app/router.tsx, app/client.tsx, and app/ssr.tsx, you’re following outdated docs. The correct root is now src/. Re-scaffold with the latest template.

How the router and Convex client wire together

The integration happens in src/router.tsx. Here’s the current template code:

// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { QueryClient } from '@tanstack/react-query'
import { routerWithQueryClient } from '@tanstack/react-router-with-query'
import { ConvexQueryClient } from '@convex-dev/react-query'
import { ConvexProvider } from 'convex/react'
import { routeTree } from './routeTree.gen'

export function getRouter() {
  const CONVEX_URL = (import.meta as any).env.VITE_CONVEX_URL!
  if (!CONVEX_URL) console.error('missing envar VITE_CONVEX_URL')

  const convexQueryClient = new ConvexQueryClient(CONVEX_URL)
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        queryKeyHashFn: convexQueryClient.hashFn(),
        queryFn: convexQueryClient.queryFn(),
        gcTime: 5000,
      },
    },
  })
  convexQueryClient.connect(queryClient)

  const router = routerWithQueryClient(
    createRouter({
      routeTree,
      defaultPreload: 'intent',
      context: { queryClient },
      scrollRestoration: true,
      defaultPreloadStaleTime: 0,
      Wrap: ({ children }) => (
        <ConvexProvider client={convexQueryClient.convexClient}>
          {children}
        </ConvexProvider>
      ),
    }),
    queryClient,
  )
  return router
}

What’s happening:

  1. ConvexQueryClient is constructed from VITE_CONVEX_URL — it bridges Convex functions with TanStack Query.
  2. QueryClient is configured with Convex’s hashFn and queryFn so queries route through Convex automatically.
  3. routerWithQueryClient from @tanstack/react-router-with-query connects the router and query client for SSR-aware data fetching.
  4. ConvexProvider wraps the app via the Wrap prop, making the Convex client available to all components.
  5. defaultPreloadStaleTime: 0 lets React Query own caching — this prevents double-fetches and stale data when using routerWithQueryClient.
Why two integration packages?

There are two co-existing packages for wiring TanStack Router with TanStack Query:

  • @tanstack/react-router-with-query — used by the official Convex template. Provides routerWithQueryClient().
  • @tanstack/react-router-ssr-query — used in the Convex quickstart docs. Provides setupRouterSsrQueryIntegration().

Both work. This guide follows the template approach (router-with-query) since it’s what you get from the scaffold. If you’re reading the Convex docs and see the other package, don’t be confused — they solve the same problem with slightly different APIs.

Basic routing with TanStack Router

TanStack Start uses file-based routing via TanStack Router. Two files define the routing foundation:

src/routes/index.tsx — the homepage:

// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
  component: Home,
})

function Home() {
  return (
    <div>
      <h1>Welcome to My SaaS App</h1>
      <p>Edit this page in <code>src/routes/index.tsx</code></p>
    </div>
  )
}

src/routes/__root.tsx — the root layout:

// src/routes/__root.tsx
import { createRootRouteWithContext, HeadContent, Outlet, Scripts } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'

interface MyRouterContext {
  queryClient: QueryClient
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: RootComponent,
})

function RootComponent() {
  return (
    <RootDocument>
      <Outlet />
    </RootDocument>
  )
}

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        {children}
        <Scripts />
      </body>
    </html>
  )
}

Key points:

  • createFileRoute('/') maps a file to a URL path.
  • createRootRouteWithContext sets up the root layout with typed context (the queryClient from the router).
  • HeadContent (replaces the old Meta) injects metadata into <head>.
  • Scripts injects JavaScript bundles at the end of <body>.
  • Outlet renders the current route’s component.

Let’s add a header with navigation and a footer to src/routes/__root.tsx. This gives every page consistent branding:

Breaking change from 2025

The old Meta import from @tanstack/start is gone. Use HeadContent from @tanstack/react-router instead. ScrollRestoration is now handled by scrollRestoration: true on the router config — no explicit component needed.

// src/routes/__root.tsx
import {
  createRootRouteWithContext,
  HeadContent,
  Link,
  Outlet,
  Scripts,
} from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'

interface MyRouterContext {
  queryClient: QueryClient
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  notFoundComponent: () => (
    <div style={{ textAlign: 'center', padding: '2rem' }}>
      <h1>404 - Page Not Found</h1>
      <Link to="/">Go Home</Link>
    </div>
  ),
  component: RootComponent,
})

function RootComponent() {
  return (
    <RootDocument>
      <header
        style={{
          padding: '1rem',
          borderBottom: '1px solid #eee',
          background: '#f8f9fa',
          position: 'sticky',
          top: 0,
          zIndex: 10,
        }}
        role="banner"
      >
        <nav aria-label="Main navigation">
          <Link
            to="/"
            style={{
              marginRight: '1rem',
              fontWeight: 'bold',
              color: '#333',
              textDecoration: 'none',
            }}
            aria-label="Home page"
          >
            My SaaS App
          </Link>
        </nav>
      </header>
      <main style={{ padding: '1rem', minHeight: '80vh' }} role="main">
        <Outlet />
      </main>
      <footer
        style={{
          padding: '1rem',
          borderTop: '1px solid #eee',
          textAlign: 'center',
          background: '#f8f9fa',
        }}
        role="contentinfo"
      >
        <p>&copy; {new Date().getFullYear()} My SaaS App. All rights reserved.</p>
      </footer>
    </RootDocument>
  )
}

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        {children}
        <Scripts />
      </body>
    </html>
  )
}

What changed from the 2025 version:

  • Meta replaced by HeadContent (imported from @tanstack/react-router, not @tanstack/start).
  • ScrollRestoration removed — handled by scrollRestoration: true in the router config.
  • notFoundComponent added to fix the 404 warning.
  • The MyRouterContext interface is simpler — the Convex client is wrapped via ConvexProvider in the router’s Wrap prop, so it doesn’t need to be in the context type.

Failure mode: If HeadContent import fails, you’re importing from @tanstack/start instead of @tanstack/react-router. Update the import.

Note on styling: This example uses inline styles for simplicity. The Convex template now ships with Tailwind v4, which you can use instead. Later articles in this series will introduce Radix UI components.

Verify your setup (build and smoke test)

Run a production build to catch type errors and confirm everything compiles:

npm run build

This runs npm run typecheck && vite build and outputs to .output/server/index.mjs (Nitro format). Then test the production server:

npm run start

Open the URL shown in the terminal. The app should render identically to dev mode.

Also verify the Convex backend:

npx convex dashboard

This opens the Convex dashboard in your browser. You should see your project listed with its functions.

You're done!

You now have a running full-stack app with:

  • SSR via TanStack Start (Nitro-powered)
  • File-based routing via TanStack Router
  • Realtime Convex backend wired through TanStack Query
  • A root layout with header, footer, and 404 handling
  • A production build that compiles without errors

Failure mode: If npm run build fails with TypeScript errors, run npm run typecheck first to isolate them. If you get OOM errors on a small VPS, increase Node memory: NODE_OPTIONS=--max-old-space-size=4096 npm run build.

Convex pricing and deployment notes

Convex has a generous free tier that’s enough for prototyping and early development:

Cost breakdown

Free plan ($0):

  • 1M function calls/month
  • 20 GB-hours action compute
  • 0.5 GB database storage
  • 1 GB file storage
  • 40 deployments
  • 1,000 concurrent sessions

Professional ($25/developer/month):

  • 25M function calls included
  • Daily backups
  • Custom domains
  • Log streaming

Business & Enterprise: $2,500/month minimum.

Pricing as of August 2026 — check convex.dev/pricing for current numbers.

Deployment architecture: Convex is a managed cloud backend — your Convex functions and database run in Convex’s cloud. The TanStack Start frontend can deploy to any Node.js host or Nitro-compatible platform. If you want to deploy your TanStack Start app on your own VPS, Dokploy is a solid option on something like Hetzner Cloud.

For deploying the Convex side to production, see our guide on how to deploy a Convex app to production. If you prefer keeping everything on your own infrastructure, you can look into self-hosting Convex — we also have benchmarks comparing Convex self-hosted vs cloud free tier performance.

Troubleshooting common issues

Missing VITE_CONVEX_URL

Symptom: Frontend can’t connect to Convex. Console shows missing envar VITE_CONVEX_URL.

Fix: Restart npm run dev. The first convex dev run bootstraps .env.local with the URL. If you started the frontend before Convex finished initializing, the variable won’t be available yet.

Verify: cat .env.local | grep VITE_CONVEX_URL should show a URL like http://127.0.0.1:3210.

app/ vs src/ confusion

Symptom: You’re creating files in app/routes/ but nothing works.

Fix: The project structure moved from app/ to src/. If you’re following an older tutorial or the 2025 version of this article, your files should go in src/routes/, src/router.tsx, etc. Re-scaffold with the latest template if unsure.

Vite plugin ordering errors

Symptom: Build fails with cryptic errors related to Vite plugins.

Fix: In vite.config.ts, viteReact() (or @vitejs/plugin-react) must come after tanstackStart() in the plugins array:

plugins: [
  tsConfigPaths({ projects: ['./tsconfig.json'] }),
  tanstackStart(),   // first
  viteReact(),       // after tanstackStart
],
verbatimModuleSyntax errors

Symptom: Server bundles leaking into client bundles, or import errors that don’t make sense.

Fix: TanStack Start docs warn that verbatimModuleSyntax in tsconfig.json “can result in server bundles leaking into client bundles.” Keep it disabled (set to false or remove it).

Port 3000 already in use

Symptom: EADDRINUSE error when starting the dev server.

Fix: Vite will auto-pick another port — check the terminal output. To force a specific port, add server: { port: 3001 } to vite.config.ts. Or kill the process using port 3000: lsof -ti:3000 | xargs kill.

Convex dashboard not loading

Symptom: The dashboard URL in the terminal doesn’t open or shows an error.

Fix: Run npx convex dashboard manually. If you’re on a remote server, the dashboard binds to localhost by default — you’ll need an SSH tunnel or firewall rule to access it from your local machine.

notFoundError warning in terminal

Symptom: Warning: A notFoundError was encountered on the route with ID "__root__", but a notFoundComponent option was not configured...

Fix: Add notFoundComponent to your root route definition (see the header/footer section above). This provides a custom 404 page instead of the default error.

What’s next in this series

Now that the foundation is in place, the next article will cover writing your first Convex query and mutation — using the tasks example from the quickstart. You’ll learn how to define a schema, write server functions, and fetch data with useSuspenseQuery and convexQuery from @convex-dev/react-query.

The series will also cover Clerk authentication, Radix UI components, and Polar.sh payments.

Build a full todo app with TanStack Start