Skip to main content

How I write server functions now

Two months after the December React advisories, I write every server action as if it were a public endpoint and keep secrets well away from it.

Updated 5 min readMarkdown
How I write server functions now

December was a rough month for anyone running React Server Components in production. Once the patching was done, I kept wondering how much of my own code would still hold up if the framework had another bad week, and for a couple of my apps the answer wasn't great.

I'd been writing server actions as if they were private functions, because that's how they feel. You put "use server" at the top, import the function into a component and call it like any other async function. Nothing about it looks like an API. But it is one: when you export a function from a "use server" file, Next.js gives it an ID and exposes it so the browser can call it, and anyone who can load your site can find that ID and POST whatever arguments they like. TypeScript types don't exist at runtime either, so an action declared as (id: string) can just as easily receive an object, an array or a 5 MB string.

So I've started writing every action the way I'd write a Go handler. Assume the input is hostile, check who's calling, and only then do the work.

Parse first, then check the user

I use zod, though any runtime validator does the job. What matters is that the schema runs on the server, inside the action, before anything else happens:

"use server";

import { z } from "zod";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";

const RenameReport = z.object({
  reportId: z.string().uuid(),
  name: z.string().trim().min(1).max(120),
});

export async function renameReport(input: unknown) {
  const user = await requireUser();
  const { reportId, name } = RenameReport.parse(input);

  await db.report.update({
    where: { id: reportId, orgId: user.orgId },
    data: { name },
  });
}

The parameter is typed unknown on purpose. It forces me to parse before I can touch anything, and it's honest about what actually arrives over the wire. Forms get the same treatment with FormData:

export async function subscribe(formData: FormData) {
  const { email } = z
    .object({ email: z.string().email().max(254) })
    .parse(Object.fromEntries(formData));
  // ...
}

After the middleware bypass last March I'd already moved auth checks for pages next to the data, but actions need it too, and they're easy to miss. So every action that touches user data starts with requireUser(), and the query is scoped to that user. In the example, where: { id: reportId, orgId: user.orgId } means a perfectly valid, logged-in user still can't rename someone else's report by guessing an ID. I've seen that exact access-control bug in code review more than once, in apps written by good developers. The helper itself is tiny:

// lib/auth.ts
import "server-only";
import { cookies } from "next/headers";

export async function requireUser() {
  const token = (await cookies()).get("session")?.value;
  const user = token ? await verifySession(token) : null;
  if (!user) throw new Error("Unauthorised");
  return user;
}

That import "server-only" line is on every module that reads secrets, talks to the database or verifies sessions now. The server-only package does nothing at runtime; its only job is to fail the build if a client component imports the file, so accidentally shipping the DB client to the browser shows up as a red build instead of an incident.

Secrets live somewhere else

One of the December follow-ups, CVE-2025-55183, exposed server function source code, and what I took from it is that server code shouldn't be relied on to stay secret. I had things like this lying around:

"use server";

const API_KEY = "sk_live_..."; // hardcoded "just for now"

export async function sendAlert() {
  await fetch("https://api.example.com/send", {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
}

Now secrets only come from the environment, read at call time in a server-only module, and never sit in the file that holds the action:

// lib/mailer.ts
import "server-only";

export async function sendAlert(to: string) {
  const key = process.env.MAILER_KEY;
  if (!key) throw new Error("MAILER_KEY missing");
  // ...
}

The action file imports sendAlert and has nothing sensitive in it, so if its source ever leaks again it makes for very dull reading.

Because actions are endpoints, they can be hammered like endpoints too. Anything that sends email, calls an LLM or runs a heavy query gets a limit. On my own box that's a simple fixed window in Postgres, keyed by user ID or IP:

export async function generateSummary(input: unknown) {
  const user = await requireUser();
  await rateLimit(`summary:${user.id}`, { max: 10, perSeconds: 3600 });
  // ...
}

On Doorstep the subscribe form also sits behind Cloudflare, so there's a second limit at the edge as well.

Patching without relying on me

December showed me how much depended on me seeing the right post on the right evening, so every repo now has the boring automation. Renovate (Dependabot would do) opens PRs for next, react and react-dom the day they're published, with security updates labelled and kept out of the grouped ones. CI runs an audit and fails on anything high or critical. Versions are pinned exactly and installs use npm ci, so what I tested is what ships, and when a security PR merges the Docker image is built with --pull and no cache so a stale layer can't keep the old package around.

# .github/workflows/ci.yml (the relevant step)
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: npm run build

None of that would stop a zero-day. It just shrinks the gap between the fix existing and the fix running, which in December was the bit that mattered. I still use server actions everywhere and I'm not going back to hand-writing an API route for every form. I just write them like the public endpoints they are.


First published on my blog. Cover photo: Rohini, CC BY-SA 4.0, via Wikimedia Commons (source).

About the author

  • Full stack engineer in London with 5+ years on production web platforms. I lead AI-assisted modernisation at Xanda: React and Go for the UK energy market, a government claims portal, and moving old Java and C++ systems onto the cloud. Outside work I run a small home server behind a Cloudflare Tunnel with a handful of public tools on it: Doorstep (rental alerts across Rightmove, OnTheMarket and OpenRent), SeenSooner (NHS A&E and planned-care waits), GridPulse (live GB grid carbon), Sift (LLM ticket triage that survives model failures) and AI Radar (a news digest written hourly by an n8n workflow). I write here about React and Next.js upgrades, Node, coding with AI agents, and the CVEs I've had to patch. Originals live at blogs.kishankanejiiya.tech.

No comments yet. Be the first to comment.