kitcn

TanStack Start

Scaffold a TanStack Start app with kitcn and Better Auth.

In this guide, we'll scaffold a fresh TanStack Start app with kitcn, layer in auth with the CLI, and then walk through the generated files so you know what the scaffold actually wrote. This page stays on the CLI path first. The file snippets later in the guide are reference output, not a separate manual install flow.

1. Scaffold

mkdir my-app
cd my-app
npx kitcn@latest init -t start --yes
pnpm dlx kitcn@latest init -t start --yes
yarn dlx kitcn@latest init -t start --yes
bunx --bun kitcn@latest init -t start --yes

init -t start creates the shadcn TanStack Start shell and layers in the kitcn baseline — backend files, client providers, local env scaffolding, and a starter messages demo.

FilePurpose
convex/functions/schema.tsStarter schema
convex/functions/messages.tsStarter query and mutation
convex/lib/crpc.tsServer-side cRPC builders
src/lib/convex/crpc.tsxReact cRPC context
src/lib/convex/convex-provider.tsxConvex + TanStack Query providers
src/lib/convex/query-client.tsQueryClient with hydration config
src/components/providers.tsxApp-level providers

2. Add Auth

Next, we'll add auth on top of the generated baseline. Keep using the published CLI runner for your package manager here as well.

npx kitcn@latest add auth --yes
pnpm dlx kitcn@latest add auth --yes
yarn dlx kitcn@latest add auth --yes
bunx --bun kitcn@latest add auth --yes

This scaffolds the auth layer on top of the baseline:

FilePurpose
src/lib/convex/auth-client.tsBetter Auth client with convexClient() plugin
src/lib/convex/auth-server.tsServer auth helpers (handler, getToken)
src/lib/convex/server.tsCaller factory with auth token support
src/routes/api/auth/$.tsAuth API endpoint
convex/functions/auth.tsAuth config and plugin registration
convex/functions/http.tsHTTP route with auth wired

3. Run

Open two terminals. In the first terminal, start the backend with the published CLI runner for your package manager:

npx kitcn@latest dev
pnpm dlx kitcn@latest dev
yarn dlx kitcn@latest dev
bunx --bun kitcn@latest dev

In the second terminal, start the app with your package manager's dev script:

terminal 2
npm run dev
# or pnpm dev
# or yarn dev
# or bun run dev

The TanStack Start app serves on http://localhost:3000 by default. kitcn dev starts the local Convex backend and keeps codegen up to date while you work.

4. Generated Files

The rest of this page is a reference for the files kitcn add auth --yes generates. We'll start with the auth client and server helpers, then move through the route and provider wiring.

Important: If you are following the CLI path, you should not need to type these files by hand. Use them to verify the scaffold output or to understand what each generated file is responsible for.

Auth Client

src/lib/convex/auth-client.ts
import { createAuthClient } from 'better-auth/react';
import { convexClient } from 'kitcn/auth/client';
import { createAuthMutations } from 'kitcn/react';

export const authClient = createAuthClient({
  baseURL:
    typeof window === 'undefined'
      ? (import.meta.env.VITE_SITE_URL as string | undefined)
      : window.location.origin,
  plugins: [convexClient()],
});

export const {
  useSignInMutationOptions,
  useSignOutMutationOptions,
  useSignUpMutationOptions,
} = createAuthMutations(authClient);

Auth Server

src/lib/convex/auth-server.ts
import { api } from '@convex/api';
import { convexBetterAuthReactStart } from 'kitcn/auth/start/server';

export const {
  handler,
  getToken,
  createCaller,
  createContext,
  fetchAuthQuery,
  fetchAuthMutation,
  fetchAuthAction,
} = convexBetterAuthReactStart({
  api,
  convexUrl: import.meta.env.VITE_CONVEX_URL!,
  convexSiteUrl: import.meta.env.VITE_CONVEX_SITE_URL!,
});

getToken fetches the Convex token from your auth route. Set auth.jwtCache: true to read it from the session cookie instead, saving the round trip. Leave it off when you prime the browser Convex client from getToken() — a cookie token can be close to expiry, and the browser client cannot renew the value it captures.

Loader Auth

Server functions and server loaders should call Convex through the caller or the fetchAuthQuery helper from auth-server.ts. Client-side route loaders that use the shared TanStack Query client can prime the shared Convex client before child loaders run:

src/routes/__root.tsx
import type { QueryClient } from '@tanstack/react-query';
import {
  Outlet,
  createRootRouteWithContext,
  redirect,
} from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { syncConvexAuthForStartLoader } from 'kitcn/auth/start';
import type { ConvexQueryClient } from 'kitcn/react';

import { Providers } from '@/components/providers';
import { getToken } from '@/lib/convex/auth-server';

const getLoaderToken = createServerFn({ method: 'GET' }).handler(async () => {
  return await getToken();
});

export const Route = createRootRouteWithContext<{
  convexQueryClient: ConvexQueryClient;
  queryClient: QueryClient;
}>()({
  beforeLoad: async ({ context }) => {
    const auth = await syncConvexAuthForStartLoader({
      convex: context.convexQueryClient,
      getToken: getLoaderToken,
    });

    return auth;
  },
  component: RootComponent,
});

export const requireAuth = (context: { isAuthenticated: boolean }) => {
  if (!context.isAuthenticated) {
    throw redirect({ to: '/auth' });
  }
};

function RootComponent() {
  return (
    <Providers>
      <Outlet />
    </Providers>
  );
}

syncConvexAuthForStartLoader accepts either a ConvexReactClient or the ConvexQueryClient from router context. It updates the browser Convex client, updates the server HTTP client during SSR, skips duplicate setAuth calls for the same token, and clears auth when no token is present.

Auth API Endpoint

src/routes/api/auth/$.ts
import { createFileRoute } from '@tanstack/react-router';

import { handler } from '@/lib/convex/auth-server';

export const Route = createFileRoute('/api/auth/$' as never)({
  server: {
    handlers: {
      GET: ({ request }) => handler(request),
      POST: ({ request }) => handler(request),
    },
  },
});

cRPC Context

src/lib/convex/crpc.tsx
import { api } from '@convex/api';
import { createCRPCContext } from 'kitcn/react';

export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
  api,
  convexSiteUrl: import.meta.env.VITE_CONVEX_SITE_URL!,
});

Providers

src/lib/convex/convex-provider.tsx
'use client';

import { QueryClientProvider as TanstackQueryClientProvider } from '@tanstack/react-query';
import { ConvexAuthProvider } from 'kitcn/auth/client';
import {
  ConvexReactClient,
  getConvexQueryClientSingleton,
  getQueryClientSingleton,
} from 'kitcn/react';
import type { ReactNode } from 'react';

import { authClient } from '@/lib/convex/auth-client';
import { CRPCProvider } from '@/lib/convex/crpc';
import { createQueryClient } from '@/lib/convex/query-client';

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL!);

export function AppConvexProvider({
  children,
}: {
  children: ReactNode;
}) {
  const queryClient = getQueryClientSingleton(createQueryClient);
  const convexQueryClient = getConvexQueryClientSingleton({
    convex,
    queryClient,
  });

  return (
    <ConvexAuthProvider
      authClient={authClient}
      client={convex}
      convexQueryClient={convexQueryClient}
    >
      <TanstackQueryClientProvider client={queryClient}>
        <CRPCProvider
          convexClient={convex}
          convexQueryClient={convexQueryClient}
        >
          {children}
        </CRPCProvider>
      </TanstackQueryClientProvider>
    </ConvexAuthProvider>
  );
}

Server Caller

createCaller() binds a caller to the current request. Every procedure call in a request shares one Convex auth token, so the token is fetched at most once per request no matter how many procedures you call:

src/lib/convex/server.ts
import { createCaller } from '@/lib/convex/auth-server';

export const caller = createCaller();

Usage:

src/functions/get-current-user.ts
import { caller } from '@/lib/convex/server';
import { createServerFn } from '@tanstack/react-start';

export const getCurrentUser = createServerFn({ method: 'GET' }).handler(
  async () => {
    return await caller.user.getSessionUser({});
  },
);

The module-scope caller holds no request state. It resolves the current request through TanStack Start's request scope on each call, so tokens are never shared between requests.

Reach caller from a createServerFn handler or a server route. A route loader also runs in the browser on client-side navigation, where there is no request scope.

To call Convex with headers other than the current request's, pass your own context factory:

const caller = createCaller(() => createContext({ headers }));

Root Route

The scaffold uses a simple root route — providers wrap the outlet:

src/routes/__root.tsx
import {
  HeadContent,
  Outlet,
  Scripts,
  createRootRoute,
} from '@tanstack/react-router';

import { Providers } from '@/components/providers';
import appCss from '../styles.css?url';

export const Route = createRootRoute({
  head: () => ({
    meta: [
      { charSet: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { title: 'TanStack Start Starter' },
    ],
    links: [{ rel: 'stylesheet', href: appCss }],
  }),
  component: RootComponent,
  shellComponent: RootDocument,
});

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

function RootComponent() {
  return (
    <Providers>
      <Outlet />
    </Providers>
  );
}

If you see an error about a missing VITE_ environment variable, add it to your local env file.

Next Steps

On this page