BETTER-CONVEX

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:

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 better-convex better-auth@1.4.9 @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 { 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:

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

And the 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
metaProcedure 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:

src/lib/convex/convex-provider.tsx
'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:

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 { 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 RSC

Query 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);
}

Next Steps

On this page