Setup
Set up better-convex with Better Auth for Next.js App Router.
In this guide, we'll set up better-convex 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 better-convex with Next.js involves these components:
| Component | Description |
|---|---|
| Caller factory | Creates server-side callers for RSC |
| Client provider | Auth-aware provider with SSR token |
| API route handler | Better Auth API routes |
| RSC helpers | Prefetching 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 better-convex better-auth@1.4.9 @tanstack/react-query superjsonNote: Complete React Setup first to create your query-client.ts with hydrationConfig.
Environment Variables
Add these to your .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# 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:3000Caller Factory
First, create a caller factory for Better Auth:
import { api } from '@convex/api';
import { meta } from '@convex/meta';
import { convexBetterAuth } from 'better-convex/auth-nextjs';
export const { createContext, createCaller, handler } = convexBetterAuth({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
meta,
});Here's what convexBetterAuth returns:
| Export | Description |
|---|---|
createContext | Creates RSC context with auth |
createCaller | Creates server-side caller |
handler | Next.js API route handler |
And the options:
| Option | Description |
|---|---|
api | Your Convex API object |
convexSiteUrl | Convex site URL (must end in .convex.site) |
auth.jwtCache | Enable/disable JWT caching (default: true) |
auth.isUnauthorized | Custom UNAUTHORIZED error detection |
meta | Procedure metadata from @convex/meta |
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:
'use client';
import { ConvexReactClient } from 'convex/react';
import { ConvexAuthProvider } from 'better-convex/auth-client';
import { convexClient } from '@convex-dev/better-auth/client/plugins';
import { inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
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:
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:
import { handler } from '@/lib/convex/server';
export const { GET, POST } = handler;RSC Setup
Now let's create the RSC helpers file:
import 'server-only';
import { api } from '@convex/api';
import { meta } from '@convex/meta';
import type { Api } from '@convex/types';
import type { FetchQueryOptions } from '@tanstack/react-query';
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import {
createServerCRPCProxy,
getServerQueryClientOptions,
} from 'better-convex/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>({ api, meta });
// 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);
}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 'better-convex/rsc';
// Server proxy - queryOptions only, no auth config
const crpc = createServerCRPCProxy({ api, meta });
crpc.posts.list.queryOptions({}); // Works
crpc.posts.create.mutationOptions(); // Not available in RSCQuery Client Options
getServerQueryClientOptions configures the QueryClient for server-side HTTP fetching:
import { getServerQueryClientOptions } from 'better-convex/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!,
},
});Helpers
Let's explore each helper function.
caller
Direct server calls - not cached, not hydrated to client. Use for server-only logic:
export const caller = createCaller(createRSCContext);
// Usage in RSC
const posts = await caller.posts.list({});prefetch
Fire-and-forget prefetch. Data fetched in background and hydrated to client:
export function prefetch<T extends { queryKey: readonly unknown[] }>(
queryOptions: T
): void {
void getQueryClient().prefetchQuery(queryOptions);
}HydrateClient
Wrapper that dehydrates prefetched queries for client hydration:
export function HydrateClient({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const dehydratedState = dehydrate(queryClient);
return (
<HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
);
}preloadQuery
Awaited fetch that returns data on the server. Equivalent to Convex's preloadQuery:
export function preloadQuery<TData>(
options: FetchQueryOptions<TData>
): Promise<TData> {
return getQueryClient().fetchQuery(options);
}