Codapult provides four complementary ways to expose server logic: REST API routes, tRPC, GraphQL, and server actions. All share the same auth, validation, and rate-limiting primitives.
API Routes
All routes live in src/app/api/ and must follow a strict five-step pattern:
import { NextResponse } from 'next/server';
import { getAppSession } from '@/lib/auth';
import { checkRateLimit } from '@/lib/rate-limit';
import { someSchema } from '@/lib/validation';
export async function POST(req: Request) {
// 1. Auth check
const session = await getAppSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. Rate limiting
const { allowed, remaining, resetAt } = checkRateLimit(`scope:${session.user.id}`, {
limit: 30,
windowSeconds: 60,
});
if (!allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please wait a moment.' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)),
},
},
);
}
// 3. Input validation (Zod)
const body = await req.json();
const data = someSchema.parse(body);
// 4. Business logic
const result = await doSomething(data);
// 5. Response
return NextResponse.json({ result }, { headers: { 'X-RateLimit-Remaining': String(remaining) } });
}
Rules:
- Always return
{ error: string }for errors — never expose stack traces or internal details. - Attach rate-limit headers:
X-RateLimit-RemainingandRetry-After. - Use Zod schemas from
@/lib/validation.tsfor input validation.
tRPC
Codapult includes a full tRPC v11 setup with TanStack React Query and superjson serialization.
Structure
| Path | Description |
| ---------------------------------- | -------------------------------------------------------------------------------------------- |
| src/lib/trpc/init.ts | Router factory, procedure levels (publicProcedure, protectedProcedure, adminProcedure) |
| src/lib/trpc/routers/ | Domain routers — user.ts, billing.ts, notifications.ts |
| src/app/api/trpc/[trpc]/route.ts | HTTP endpoint at /api/trpc |
Procedure Levels
| Level | Auth Required | Use Case |
| -------------------- | ---------------- | ------------------------------------ |
| publicProcedure | No | Public data (plans, features) |
| protectedProcedure | Yes | User-scoped data (profile, settings) |
| adminProcedure | Yes + admin role | Admin operations (user management) |
Creating a Router
import { z } from 'zod';
import { router, protectedProcedure } from '../init';
import { db } from '@/lib/db';
import { user } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
export const userRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
const rows = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, ctx.session.user.id))
.limit(1);
return rows[0] ?? null;
}),
updateProfile: protectedProcedure
.input(z.object({ name: z.string().min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
await db
.update(user)
.set({ name: input.name, updatedAt: new Date() })
.where(eq(user.id, ctx.session.user.id));
return { success: true };
}),
});
Server-Side Prefetch
Use prefetch() in server components and hydrate on the client:
import { prefetch } from '@/lib/trpc/server';
import { HydrateClient } from '@/lib/trpc/client';
export default async function ProfilePage() {
await prefetch((t) => t.user.me.prefetch());
return (
<HydrateClient>
<ProfileClient />
</HydrateClient>
);
}
Client-Side Usage
'use client';
import { useTRPC } from '@/lib/trpc/client';
import { useQuery } from '@tanstack/react-query';
export function ProfileClient() {
const trpc = useTRPC();
const { data: user } = useQuery(trpc.user.me.queryOptions());
return <h1>Hello, {user?.name}</h1>;
}
GraphQL
An optional graphql-yoga server is available at /api/graphql.
| Path | Description |
| ------------------------------ | -------------------------- |
| src/lib/graphql/schema.ts | SDL type definitions |
| src/lib/graphql/resolvers.ts | Resolver implementations |
| src/app/api/graphql/route.ts | HTTP endpoint (GET + POST) |
The GraphQL layer is an independent module — remove it if you only need tRPC or REST. See docs/MODULES.md for removal instructions.
Server Actions
All mutations go through server actions in src/lib/actions/. Every action follows this pattern:
'use server';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { getAppSession } from '@/lib/auth';
import { checkRateLimit } from '@/lib/rate-limit';
import { updateProfileSchema } from '@/lib/validation';
export async function updateProfile(formData: FormData): Promise<void> {
const session = await getAppSession();
if (!session) redirect('/sign-in');
const { allowed } = checkRateLimit(`settings:${session.user.id}`, {
limit: 10,
windowSeconds: 60,
});
if (!allowed) throw new Error('Too many requests. Please wait a moment.');
const data = updateProfileSchema.parse({
name: formData.get('name'),
});
// ... DB update ...
revalidatePath('/dashboard/settings');
}
Available Actions
| Action | File | Description |
| -------------------- | --------------- | ----------------------------------- |
| createCheckout | billing.ts | Create a Stripe/LS checkout session |
| openCustomerPortal | billing.ts | Open the billing portal |
| updateProfile | settings.ts | Update user name and preferences |
| deleteAccount | settings.ts | Delete the current user account |
| completeOnboarding | onboarding.ts | Mark onboarding as complete |
| setLocale | locale.ts | Change the user's locale |
| changeUserRole | admin.ts | Admin: change a user's role |
| deleteUser | admin.ts | Admin: delete a user |
| createApiKeyAction | api-keys.ts | Generate a new API key |
| deleteApiKeyAction | api-keys.ts | Revoke an API key |
API Versioning
Codapult supports URL-prefix versioning via a proxy layer in src/proxy.ts.
How It Works
Requests to /api/v1/users are rewritten to /api/users internally. The version is extracted and tracked through the request lifecycle.
GET /api/v1/users → internally routed to → GET /api/users
Version Endpoint
GET /api/version returns the current API version status:
{
"current": "v1",
"supported": ["v1", "v2"],
"versions": {
"v1": { "deprecated": false },
"v2": { "deprecated": false }
}
}
Response Headers
Every versioned response includes:
| Header | Description |
| --------------- | ----------------------------------------- |
| X-API-Version | The resolved API version |
| Deprecation | true if the version is deprecated |
| Sunset | ISO date when the version will be removed |
| Link | URL of the successor version |
Clients can send X-API-Version as a request header to opt into version-specific behavior.
Configuration
Version definitions live in src/lib/api-version.ts:
export const API_VERSIONS = ['v1', 'v2'] as const;
export const CURRENT_VERSION: ApiVersion = 'v1';
export const VERSION_INFO: Record<ApiVersion, { deprecated: boolean; sunset?: string }> = {
v1: { deprecated: false },
v2: { deprecated: false },
};
Rate Limiting
Codapult includes a sliding-window rate limiter in src/lib/rate-limit.ts.
Usage
import { checkRateLimit } from '@/lib/rate-limit';
const { allowed, remaining, resetAt } = checkRateLimit(`chat:${session.user.id}`, {
limit: 30,
windowSeconds: 60,
});
The checkRateLimit function returns:
| Field | Type | Description |
| ----------- | --------- | ------------------------------------- |
| allowed | boolean | Whether the request is within limits |
| remaining | number | Remaining requests in the window |
| resetAt | number | Timestamp (ms) when the window resets |
Where It's Applied
| Scope | Limit | Window | | --------------- | -------------------- | ------ | | Auth endpoints | Configured per route | 60s | | AI chat | 30 requests | 60s | | Billing actions | 5 requests | 60s | | Admin actions | 10 requests | 60s | | GraphQL | 60 requests | 60s | | Plugin routes | 30 requests | 60s |
Note: The built-in limiter is in-memory and not shared across serverless instances. For production, swap it with a Redis-backed implementation (e.g. Upstash Rate Limit).