Backend
Server-side code: API routes, business logic, third-party integrations, webhooks. With Next.js App Router, the line between frontend and backend has blurred — this skill covers everything that runs on the server.
When to use this
- The user is writing API endpoints or route handlers.
- The user is integrating a third-party service (Stripe, Resend, etc.).
- The user is handling webhooks from an external provider.
- The user needs background jobs, queues, or scheduled tasks.
Stack-aware
This skill works for two common deployment paths:
- Stack A (serverless on Vercel): Server Components, Server Actions, Route Handlers. 10s function timeout on Hobby, 60s on Pro. Background work goes to Trigger.dev/Inngest.
- Stack B (long-running on Render): Same Next.js code, but functions can run for minutes (no serverless timeout). Background work can run in-process. Cron jobs via
render.yaml.
Both work. Pick A for traffic spikes and edge globalness. Pick B for AI features that need long timeouts, websockets, or anything where serverless cold starts hurt.
The mental model (Next.js App Router)
In modern Next.js, server code lives in three places:
- Server Components — render on the server, can fetch data directly with
async/await. Default for anypage.tsxorlayout.tsxnot marked"use client". - Server Actions — async functions marked
"use server"that you call from client components like a regular function. They run on the server. - Route Handlers —
app/api/<route>/route.tsfiles that export HTTP handlers (GET,POST, etc.). Use these for webhooks, public APIs, and anything called by external services.
When to use which:
- Reading data on a page → Server Component
- Form submissions, mutations from your own UI → Server Action
- Webhooks, public APIs, file uploads from third parties → Route Handler
Server Components (data fetching)
// app/dashboard/page.tsx — runs on the server
import { db } from "@/lib/db";
import { recipes } from "@/lib/schema";
import { eq } from "drizzle-orm";
export default async function DashboardPage() {
const userRecipes = await db
.select()
.from(recipes)
.where(eq(recipes.userId, "current-user-id"));
return (
<div>
{userRecipes.map((recipe) => (
<div key={recipe.id}>{recipe.title}</div>
))}
</div>
);
}
No useEffect, no loading state, no client fetching. Just await the data.
Server Actions (mutations)
// app/actions/create-recipe.ts
"use server";
import { db } from "@/lib/db";
import { recipes } from "@/lib/schema";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
export async function createRecipe(formData: FormData) {
const session = await auth();
if (!session) throw new Error("Unauthorized");
const title = formData.get("title") as string;
if (!title || title.length < 2) {
return { error: "Title is required" };
}
await db.insert(recipes).values({ title, userId: session.user.id });
revalidatePath("/dashboard");
return { ok: true };
}
Then call it from a client component:
"use client";
import { createRecipe } from "@/app/actions/create-recipe";
<form action={createRecipe}>
<input name="title" />
<button type="submit">Create</button>
</form>
Route Handlers (webhooks, public APIs)
// app/api/contact/route.ts
import { contactSchema } from "@/lib/schemas";
import { resend } from "@/lib/resend";
export async function POST(req: Request) {
const body = await req.json();
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: parsed.error.flatten() },
{ status: 400 }
);
}
await resend.emails.send({
from: "noreply@yoursite.com",
to: "you@yoursite.com",
subject: `Contact: ${parsed.data.subject}`,
text: parsed.data.message,
});
return Response.json({ ok: true });
}
Third-party integrations
The pattern is always the same:
- Install the SDK:
npm install @company/sdk - Get your API key from the provider's dashboard
- Add to .env.local as
PROVIDER_API_KEY=... - Create a server-only client in
lib/<provider>.ts - Import and call from server components, server actions, or route handlers
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20.acacia",
});
Common integrations and their packages:
- Stripe →
stripe - Email →
resend - AI →
@anthropic-ai/sdk,openai,@ai-sdk/openai - File upload →
@aws-sdk/client-s3, Supabase Storage SDK,uploadthing - Auth →
@clerk/nextjs,@supabase/supabase-js - Analytics →
@vercel/analytics,posthog-node - Error tracking →
@sentry/nextjs
Webhooks
Webhooks are how third parties tell you something happened (a payment succeeded, an email bounced, a user updated their profile).
The five rules of webhooks:
- Always verify the signature. Every reputable provider signs webhooks. Verify before trusting any data.
- Return 200 fast. Acknowledge receipt within a few seconds. Process slow work in a background job.
- Be idempotent. The same webhook may arrive twice. Use the event ID to dedupe.
- Log everything. When a webhook breaks, you need the raw payload to debug.
- Use a separate endpoint per provider. Don't mix Stripe and Resend in the same route.
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
export async function POST(req: Request) {
const body = await req.text();
const sig = (await headers()).get("stripe-signature")!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// Handle the event
switch (event.type) {
case "checkout.session.completed":
// ... fulfill the order
break;
}
return Response.json({ received: true });
}
Rate limiting
Public endpoints need rate limiting. Use Upstash Redis with their rate limit package — it's serverless-friendly and free at small scale:
npm install @upstash/ratelimit @upstash/redis
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"), // 10 requests per 10 seconds
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await ratelimit.limit(ip);
if (!success) {
return new Response("Too many requests", { status: 429 });
}
// ... handle the request
}
Caching
Next.js has aggressive caching. Two key patterns:
unstable_cache for expensive queries:
import { unstable_cache } from "next/cache";
const getPopularRecipes = unstable_cache(
async () => db.select().from(recipes).limit(10),
["popular-recipes"],
{ revalidate: 60, tags: ["recipes"] }
);
revalidatePath / revalidateTag to invalidate after mutations:
import { revalidatePath, revalidateTag } from "next/cache";
// After creating a recipe
revalidatePath("/recipes");
revalidateTag("recipes");
Background jobs
For anything that takes more than a few seconds (sending bulk emails, generating reports, scraping), use a queue. Modern picks:
- Trigger.dev — easy DX, built for Next.js
- Inngest — durable workflows, good for complex flows
- Cloudflare Queues — if you're on Workers
- Vercel Cron + a route handler — for simple scheduled tasks
Common mistakes to avoid
- Putting secrets in client code. Server-only env vars must NOT have
NEXT_PUBLIC_prefix. - Calling external APIs from client components. Proxy through your own route handler instead — keeps the API key safe and avoids CORS.
- Forgetting to verify webhook signatures. Without this, anyone can hit your endpoint.
- Using fetch instead of the SDK. SDKs handle retries, errors, and types for you.
- Long-running route handlers. Vercel functions time out at 10s (Hobby) / 60s (Pro). Use a background job for slow work.
- Hard-coding URLs. Use env vars so dev/staging/prod work the same.
- No error handling. Every external call can fail — wrap in try/catch.
Going deeper
- Backend: https://www.codebooks.ai/backend
- API Integration: https://www.codebooks.ai/api-integration
- Environment Variables: https://www.codebooks.ai/env-variables
- Real-Time Features: https://www.codebooks.ai/realtime
