Lesson 03 of 04

Mutations without an API layer

Intermediate10 min

Server actions remove a whole tier of boilerplate. They also remove the tier where you were doing your validation, so put it back.

OutcomesAfter this you will be able to

  • Write a server action with proper validation and auth checks
  • Return typed errors to a form without throwing
  • Explain why a server action is a public endpoint

01A server action is a public HTTP endpoint

This is the single most important thing to internalise. A server action looks like a function call, but Next compiles it into a routable endpoint with a generated id. Anyone can POST to it with any arguments they like. The fact that your UI only calls it from an admin page is not a security control.

app/actions/delete-project.ts
'use server';

import { z } from 'zod';
import { getSession } from '@/lib/auth';

const Input = z.object({ id: z.string().uuid() });

export async function deleteProject(raw: unknown) {
  // 1. authenticate — who is calling?
  const session = await getSession();
  if (!session) return { ok: false, error: 'Not signed in' } as const;

  // 2. validate — is the input the shape we expect?
  const parsed = Input.safeParse(raw);
  if (!parsed.success) return { ok: false, error: 'Bad request' } as const;

  // 3. authorise — may THIS user touch THIS row?
  const project = await db.query.projects.findFirst({
    where: eq(projects.id, parsed.data.id),
  });
  if (project?.ownerId !== session.userId) {
    return { ok: false, error: 'Not found' } as const;
  }

  await db.delete(projects).where(eq(projects.id, parsed.data.id));
  revalidatePath('/projects');
  return { ok: true } as const;
}

Authenticate, validate, authorise, then act. Skipping step three is the classic bug: a signed-in user is allowed to delete somebody else's row because the code only checked that *someone* was logged in.

02Return errors, do not throw them

A thrown error in a server action becomes a generic digest in production — deliberately, so you cannot leak a stack trace to a user. That is correct for bugs and useless for "that email is already taken". Expected failures are return values.

form component
'use client';
import { useActionState } from 'react';

export function DeleteButton({ id }: { id: string }) {
  const [state, action, pending] = useActionState(
    async () => deleteProject({ id }),
    null,
  );

  return (
    <form action={action}>
      <button disabled={pending}>{pending ? 'Deleting…' : 'Delete'}</button>
      {state?.ok === false && (
        <p role="alert">{state.error}</p>
      )}
    </form>
  );
}

03Keep the form working without JavaScript

Passing an action to a `<form action={...}>` gives you a form that submits even before hydration finishes — a real benefit on slow connections, where the gap between "visible" and "interactive" can be seconds. Wiring the same action to an `onClick` throws that away for nothing.

  • Prefer `<form action={fn}>` over `onClick={fn}`.
  • Read values from the passed `FormData` rather than component state where you can.
  • Use `useFormStatus` inside the form for pending UI, so the button does not need the state lifted.

Exercise

Attack your own action

Take a server action from a project you have built. Open devtools, copy the POST request it produces as curl, and replay it with an id belonging to another user. If it succeeds, you just found the authorisation gap this lesson is about. Fix it, then replay the request again.