launch-quickly
Patterns

Server actions

How to write a mutation in this project — the typed action client, its staged builder, ActionResult, and the AppError taxonomy. Use when adding or changing anything in an actions.ts file, or when the compiler says .mutation() does not exist.

Generated from .claude/skills/writing-server-actions/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.

Every Server Action is a public POST endpoint. Anyone who can guess the action id can call it, with any arguments. That is the footgun this builder exists to close.

The shape

'use server';

export const createProject = action
  .input(createProjectSchema) // required — zod, from validation.ts
  .authed() // or .public() — one is required
  .can('projects.create') // optional, repeatable
  .rateLimit('mutations') // required after .public()
  .mutation<Project>(async ({ input, ctx }) => {
    const project = await withTenant(ctx, (tx) =>
      insertProject(tx, ctx, input),
    );
    revalidatePath('/projects');
    return project;
  });

input is already parsed and typed. ctx is a TenantContext after .authed() and null after .public().

The builder is a type-level state machine

.mutation() does not exist on the type until you have called .input() and chosen an auth mode, and .public() returns a stage that only has .rateLimit():

action → .input(schema) → InputStage
InputStage       has: authed(), public()          — no mutation()
AuthedStage      has: can(), rateLimit(), mutation()
PublicStage      has: rateLimit()                 — no mutation()
PublicThrottled  has: mutation()

So if it compiles, it is validated, authenticated (or explicitly public), and throttled. There is no per-action auth code to forget, which is stronger than any lint rule could be.

If you see:

Property 'mutation' does not exist on type 'InputStage<…>'

you skipped the auth mode. Add .authed() — or .public().rateLimit(bucket) if this genuinely must work for anonymous callers. Do not cast, and do not reach for any.

Actions stay thin

validate → authorize → call mutations.ts → revalidate.

The first two are the builder's job. Business logic belongs in mutations.ts; actions.ts must not import drizzle. Keeping actions thin is what lets the upgrade codemods rewrite them.

Return values, not exceptions

Actions return a discriminated union and never throw to the client:

type ActionResult<TData> =
  | { ok: true; data: TData }
  | {
      ok: false;
      error: {
        code: ErrorCode;
        message: string;
        fieldErrors?: Record<string, string>;
      };
    };

An action's failure is usually something the UI should render — a field error, a permission message — not a crash. Queries in Server Components are the opposite: they throw, and error.tsx catches them.

Errors

Throw an AppError from @/lib/errors, never a bare Error or a string:

throw notFound('That project does not exist.');
throw forbidden();
throw conflict('That project is already archived.');
throw validation('Check the highlighted fields.', { name: 'Already taken.' });
codestatusmessage reaches the client
UNAUTHORIZED401yes
FORBIDDEN403yes
NOT_FOUND404yes
CONFLICT409yes
VALIDATION422yes
RATE_LIMITED429yes
PAYMENT_REQUIRED402yes
INTERNAL500no

INTERNAL is the fallback for anything unrecognised and the only code whose message is withheld — an unexpected throw can carry a connection string or a query. Which is exactly why throw new Error('That project is archived') in an actions.ts is a lint error: the user gets "Something went wrong" and the real reason only exists in the logs.

validation() takes fieldErrors, and useActionForm maps them back onto the fields that caused them. Use it instead of a generic message when you know which field is wrong.

A privileged enum value is not protected by gating one action

This one is easy to miss and the generator does not help you. lq generate feature puts every value of an enum field into the create and update schemas, the mutation's value type, and the form's <select>. So given status:enum(draft,sent,paid,void), adding a voidInvoice action gated with .can('billing.manage') protects nothing on its own: anyone holding invoices.update can set status: 'void' through the ordinary edit form.

Narrow the ordinary path instead of only guarding the special one:

// schema.ts — the values an ordinary edit may set
export const editableInvoiceStatuses = ['draft', 'sent', 'paid'] as const;

Use that narrowed list in validation.ts, in the mutation's value type and in the form. The privileged value then becomes unreachable except through the gated action — as a type error and a validation error, not just a convention.

lq check cannot catch this: every file is individually correct.

Schemas are never redeclared

Import the schema from the feature's validation.ts. The action validates with it and the form resolves against the same object, so there is no second copy to drift. If you need a variant, derive it (.extend(), .pick(), .omit()) rather than writing a parallel one.

The one documented exception

Marketing forms use the raw (prevState, FormData) signature so they submit before JavaScript loads — progressive enhancement matters on a landing page and does not behind a login wall. src/features/waitlist/actions.ts is the shipped example, and it carries an explicit eslint-disable with a reason. Do not copy that pattern into application code.

Before you finish

Run lq check. It typechecks (which is where the builder's guarantees are enforced), lints, and checks structure. A feature that writes data needs a test — lq generate feature ships a tenant-isolation test by default, and lq check fails a feature with mutations and no test at all.

On this page