kitcn

Server Side Calls

Call procedures from server-side code without HTTP overhead.

Environment Setup

The caller requires the Convex site URL (for HTTP actions), which is different from the real-time URL.

Note: The site URL uses port 3211 locally and .site domain in production.

.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
.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

Create Caller

Without Auth

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

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

Better Auth (Next.js)

For Better Auth with Next.js, see Next.js Setup which uses convexBetterAuth from kitcn/auth/nextjs.

Custom Auth

Use createCallerFactory with a custom getToken function.

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

export const { createContext, createCaller } = createCallerFactory({
  api,
  convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
  auth: {
    getToken: async (siteUrl, headers) => {
      // Extract token from headers (implement your auth logic)
      const token = headers.get('authorization')?.replace('Bearer ', '');
      return { token };
    },
  },
});

Note: The generated api object already includes cRPC metadata. No separate meta parameter is needed.

If you customize transformer, use the same transformer in:

  • initCRPC.create({ transformer })
  • createCRPCContext({ transformer })
  • createCallerFactory({ transformer })
  • getServerQueryClientOptions({ transformer })

Date support stays always on; custom transformer is additive only.

createCallerFactory vs generated runtime factories

There are two caller APIs, each for a different context:

APILocationWhen to useTransport
createCallerFactorykitcn/serverServer-side HTTP calls (Next.js API routes, RSC)Convex fetch APIs
create<Module>Handler(ctx)convex/functions/generated/<module>.runtime.tsQuery/mutation composition inside Convex handlersDirect handler
create<Module>Caller(ctx)convex/functions/generated/<module>.runtime.tsAction/HTTP composition inside Convex handlersrunQuery, runMutation, runAction, and scheduler

Use create<Module>Handler(ctx) as the default inside queries and mutations. Use create<Module>Caller(ctx) in actions and HTTP routes, where handler factories are unavailable.

convex/functions/teams.ts
import { z } from 'zod';
import { privateQuery } from '../lib/crpc';
import { createTeamsHandler } from './generated/teams.runtime';

export const getTeamAndOwner = privateQuery
  .input(z.object({ teamId: z.string() }))
  .query(async ({ ctx, input }) => {
    const handler = createTeamsHandler(ctx);
    const team = await handler.getTeam({ teamId: input.teamId });
    const owner = await handler.getOwner({ id: team.ownerId });
    return { team, owner };
  });

Note: In actions, each create<Module>Caller(ctx) call runs as a separate Convex transaction. Prefer aggregating related reads/writes into a single internal query/mutation when consistency matters.

For the full call matrix and type definitions, see API Reference below. For more details on composition, see In-Process Procedure Composition.

Usage

Create context from headers, then use ctx.caller.

src/app/api/users/route.ts
import { createContext } from '@/lib/convex/server';

// Create context from request headers
const ctx = await createContext({ headers: request.headers });

// Query
const users = await ctx.caller.user.list({});

// Query with input
const user = await ctx.caller.user.getById({ id: userId });

// Mutation
const newId = await ctx.caller.user.create({
  name: 'John',
  email: 'john@example.com',
});

// Check auth state
if (!ctx.isAuthenticated) {
  redirect('/login');
}

Hono Middleware

src/middleware.ts
const ctx = await createContext({ headers: c.req.raw.headers });
const user = await ctx.caller.user.getSessionUser({});

API Routes

src/app/api/data/route.ts
const ctx = await createContext({ headers: request.headers });
const data = await ctx.caller.user.list({});

API Reference

Call Matrix

What each caller context can invoke via create<Module>Caller(ctx):

Caller contextRoot queryRoot mutationRoot actioncaller.actions.*caller.schedule.*
QueryCtx
MutationCtx
ActionCtx

If ctx is already typed as ActionCtx, call the generated caller directly. Use caller.actions.* only from a real action context. If the flow can run from a mutation or generic scheduler-capable callback, do not force requireActionCtx(ctx) just to reach an action. Schedule it instead:

import { requireSchedulerCtx } from 'kitcn/server';

const caller = createJobsCaller(requireSchedulerCtx(ctx));
await caller.schedule.now.reindex({ force: true });

Only use requireActionCtx(ctx) when the callback truly runs in ActionCtx and you need caller.actions.*.

createCallerFactory

Creates a caller factory with token management and retry logic.

types.ts
type CreateCallerFactoryOptions<TApi> = {
  api: TApi;
  convexSiteUrl: string;
  auth?: {
    getToken: (
      siteUrl: string,
      headers: Headers,
      opts?: unknown
    ) => Promise<{ token?: string; isFresh?: boolean }>;
    isUnauthorized?: (error: unknown) => boolean;
  };
  // Additive only. Built-in Date transformer remains enabled.
  transformer?: DataTransformer | { input: DataTransformer; output: DataTransformer };
};

Returns { createContext, createCaller }:

types.ts
type ConvexContext<TApi> = {
  token: string | undefined;
  isAuthenticated: boolean;
  caller: ServerCaller<TApi>;
};

// createContext: (opts: { headers: Headers }) => Promise<ConvexContext<TApi>>
// createCaller: (ctxFn: () => Promise<ConvexContext<TApi>>) => LazyCaller<TApi>

Next Steps

On this page