BETTER-CONVEX

Setup

Set up kitcn with Better Auth for Next.js App Router.

In this guide, we'll set up kitcn with Better Auth for Next.js App Router. You'll configure environment variables, create the caller factory, set up the client provider, and prepare RSC helpers for server-side rendering.

Overview

Setting up kitcn with Next.js involves these components:

ComponentDescription
Caller factoryCreates server-side callers for RSC
Client providerAuth-aware provider with SSR token
API route handlerBetter Auth API routes
RSC helpersPrefetching and hydration utilities

Let's set it up.

Prerequisite: Complete Auth Server first to configure your Convex backend.

Installation

First, install the required packages:

bun add kitcn better-auth@1.5.3 @tanstack/react-query superjson

Note: Complete React Setup first to create your query-client.ts with hydrationConfig.

Environment Variables

Add these to your .env.local:

.env.local
# WebSocket API (port 3210)
NEXT_PUBLIC_CONVEX_URL=http://localhost:3210

# HTTP routes (port 3211)
NEXT_PUBLIC_CONVEX_SITE_URL=http://localhost:3211

# Better Auth
NEXT_PUBLIC_SITE_URL=http://localhost:3000
.env.local
# Generated by Convex
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud

# Add manually - replace .cloud with .site
NEXT_PUBLIC_CONVEX_SITE_URL=https://your-project.convex.site

# Better Auth
NEXT_PUBLIC_SITE_URL=http://localhost:3000

Caller Factory

First, create a caller factory for Better Auth:

src/lib/convex/server.ts
import { api } from '@convex/api';
import { convexBetterAuth } from 'kitcn/auth/nextjs';

export const { createContext, createCaller, handler } = convexBetterAuth({
  api,
  convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});

See the API Reference below for full details on what convexBetterAuth returns and its options.

Note: Not using Better Auth? See Server Side Calls.

Client Provider

Set up the client provider with Better Auth. The initialToken prop enables authenticated SSR:

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

import { ConvexReactClient } from 'convex/react';
import { ConvexAuthProvider } from 'kitcn/auth/client';
import { inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import { convexClient } from 'kitcn/auth/client';

import type { Auth } from '@convex/auth-shared';

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_SITE_URL,
  plugins: [inferAdditionalFields<Auth>(), convexClient()],
});

export function ConvexProvider({
  children,
  token,
}: {
  children: React.ReactNode;
  token?: string;
}) {
  return (
    <ConvexAuthProvider
      client={convex}
      authClient={authClient}
      initialToken={token}
    >
      {children}
    </ConvexAuthProvider>
  );
}

Use in your layout RSC to fetch the token server-side:

src/app/(app)/layout.tsx
import { ConvexProvider } from '@/lib/convex/convex-provider';
import { caller } from '@/lib/convex/rsc';

export async function Layout({ children }: { children: React.ReactNode }) {
  const token = await caller.getToken();

  return <ConvexProvider token={token}>{children}</ConvexProvider>;
}

This pattern ensures authenticated queries work on the initial SSR render.

API Route Handler

Create the Next.js API route for Better Auth:

src/app/api/auth/[...all]/route.ts
import { handler } from '@/lib/convex/server';

export const { GET, POST } = handler;

RSC Setup

Now let's create the RSC helpers file:

src/lib/convex/rsc.tsx
import 'server-only';

import { api } from '@convex/api';
import type { FetchQueryOptions } from '@tanstack/react-query';
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query';
import {
  createServerCRPCProxy,
  getServerQueryClientOptions,
} from 'kitcn/rsc';
import { headers } from 'next/headers';
import { cache } from 'react';

import { hydrationConfig } from './query-client';
import { createCaller, createContext } from './server';

// RSC context factory - wraps createContext with cache() and next/headers
const createRSCContext = cache(async () =>
  createContext({ headers: await headers() })
);

// Server caller - direct calls without caching/hydration
export const caller = createCaller(createRSCContext);

// Server-compatible cRPC proxy (queryOptions only)
export const crpc = createServerCRPCProxy({ api });

// Create server QueryClient with HTTP-based fetching
function createServerQueryClient() {
  return new QueryClient({
    defaultOptions: {
      ...hydrationConfig, // SuperJSON serialization for SSR
      ...getServerQueryClientOptions({
        getToken: caller.getToken,
        convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
      }),
    },
  });
}

// Cache QueryClient per request
export const getQueryClient = cache(createServerQueryClient);

// Fire-and-forget prefetch for client hydration
export function prefetch<T extends { queryKey: readonly unknown[] }>(
  queryOptions: T
): void {
  void getQueryClient().prefetchQuery(queryOptions);
}

// Hydration wrapper for client components
export function HydrateClient({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient();
  const dehydratedState = dehydrate(queryClient);

  return (
    <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
  );
}

/**
 * Preload a query - returns data + hydrates for client.
 * Use for server-side data access (metadata, conditionals).
 */
export function preloadQuery<
  TQueryFnData = unknown,
  TError = Error,
  TData = TQueryFnData,
  TQueryKey extends readonly unknown[] = readonly unknown[],
>(
  options: FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>
): Promise<TData> {
  return getQueryClient().fetchQuery(options);
}

The RSC file above exports these helpers. See the Helpers reference for descriptions of each.

Next Steps

API Reference

convexBetterAuth

convexBetterAuth returns:

ExportDescription
createContextCreates RSC context with auth
createCallerCreates server-side caller
handlerNext.js API route handler

Options:

OptionDescription
apiYour Convex API object
convexSiteUrlConvex site URL (must end in .convex.site)
auth.jwtCacheEnable/disable JWT caching (default: true)
auth.isUnauthorizedCustom UNAUTHORIZED error detection

Helpers

The RSC Setup exports these helpers (see code above for implementations):

HelperDescription
callerDirect server calls - not cached, not hydrated to client. Use for server-only logic.
prefetchFire-and-forget prefetch. Data fetched in background and hydrated to client.
HydrateClientWrapper that dehydrates prefetched queries for client hydration.
preloadQueryAwaited fetch that returns data on the server. Equivalent to Convex's preloadQuery.

Server Proxy

createServerCRPCProxy creates a cRPC proxy for Server Components:

  • Only supports queryOptions (no mutations in RSC)
  • Generates the same query keys as the client proxy
import { createServerCRPCProxy } from 'kitcn/rsc';

// Server proxy - queryOptions only, no auth config
const crpc = createServerCRPCProxy({ api });
crpc.posts.list.queryOptions({}); // Works
crpc.posts.create.mutationOptions(); // Not available in RSC

Query Client Options

getServerQueryClientOptions configures the QueryClient for server-side HTTP fetching:

import { getServerQueryClientOptions } from 'kitcn/rsc';

// With auth + HTTP routes
const queryClient = new QueryClient({
  defaultOptions: {
    ...getServerQueryClientOptions({
      getToken: caller.getToken,
      convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
    }),
  },
});

// Without auth (public queries only)
const queryClient = new QueryClient({
  defaultOptions: {
    ...getServerQueryClientOptions(),
    convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
  },
});

Date support is always on. If you pass a custom transformer, it is additive and should be shared across server, client, and caller boundaries.

On this page