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.' });| code | status | message reaches the client |
|---|---|---|
| UNAUTHORIZED | 401 | yes |
| FORBIDDEN | 403 | yes |
| NOT_FOUND | 404 | yes |
| CONFLICT | 409 | yes |
| VALIDATION | 422 | yes |
| RATE_LIMITED | 429 | yes |
| PAYMENT_REQUIRED | 402 | yes |
| INTERNAL | 500 | no |
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.
Data access and tenancy
How to read and write data in this project — withTenant, withPublic, withSystem, TenantContext, and the row-level security that backs them. Use when adding a query or mutation, when a query returns no rows unexpectedly, or when deciding where an authorization check belongs.
Authorization
Who may do what — role permissions with can(), and resource-level rules (owner, assignee) in a slice's policy.ts. Use when a rule depends on the ROW and not just the role, or when tempted to compare ctx.userId in a component.