Server Actions
Server mode only. Hand a form submission to a Server Action to handle it on the server and, if needed, move to another page with redirect().
On this page
Defining an action
src/lib/actions.ts'use server';
import { saveTalk } from './talks.server';
export type TalkState = { error?: string };
export async function createTalk(
_previous: TalkState,
formData: FormData,
): Promise<TalkState> {
const title = formData.get('title');
if (typeof title !== 'string' || title === '') {
return { error: 'Enter a title' };
}
await saveTalk(title);
return {};
}Every export of a module that starts with 'use server' is a Server Action. Each one is an async function.
An action passed to useActionState receives the previous result and the FormData, and returns the next result.
src/components/talk-form.tsx'use client';
import { useActionState } from 'react';
import { createTalk } from '../lib/actions';
export function TalkForm() {
const [state, formAction, pending] = useActionState(createTalk, {});
return (
<form action={formAction}>
<input aria-label="Title" name="title" />
<button disabled={pending} type="submit">
Add
</button>
{state.error !== undefined && (
<p role="alert">{state.error}</p>
)}
</form>
);
}A Client Component hands the action to useActionState. Pass the formAction it returns to the form’s action, and submitting calls the action. pending is true only while a submission is under way.
Round trip
Calling an action has the server render the current page again and send it back with the return value. By the time the caller has the value, the screen is up to date. That is a single round trip.
Before JavaScript loads
React renders hidden fields that identify the action into the HTML. A form submitted before JavaScript loads posts as an ordinary form. The server runs the action and returns the same page, rendered again.
useActionState returns the action’s result in the render after that submission too. The same component works before and after JavaScript loads.
'use server' and server-only
'use server' goes on a function the browser may call. server-only goes on a module the browser must never receive. An actions module needs only 'use server'.
Keep secrets and database clients in a *.server.ts and import it from the action. The browser receives only a reference to the action, so importing a *.server.ts from it does not trip server-only.
An actions module is imported from the browser, so it is not named *.server.ts. For more on server-only, see Server and browser.
Redirecting with redirect()
src/lib/add-talk.ts'use server';
import { href } from '@k8ordo/framework';
import { redirect } from '@k8ordo/framework/server';
import { saveTalk } from './talks.server';
export async function addTalk(formData: FormData): Promise<void> {
const title = formData.get('title');
if (typeof title === 'string' && title !== '') {
await saveTalk(title);
}
redirect(href('/talks'));
Built with `href()`, so it carries Vite’s `base` when the app is served under one
}src/routes/talks/new/page.tsximport { addTalk } from '../../../lib/add-talk';
export default function NewTalkPage() {
return (
<form action={addTalk}>
<input aria-label="Title" name="title" />
<button type="submit">Add</button>
</form>
);
}To move to another page once the submission is done, end the action with redirect() from @k8ordo/framework/server. A Server Component can hand the action straight to <form action>, with no Client Component.
Pitfall
redirect() does not return; it leaves the action as an exception, and the lines after it never run. Inside a try, the catch would receive it, so call it outside.
A form submitted before JavaScript loaded is answered with a 303 and a Location header, and the browser loads the target with a GET. A call from the client runtime gets a payload that carries the target, and the router navigates there.
redirect() does not add base to the URL it is given. There is no API for redirecting while a page renders; when a page’s URL changes, add a redirect.ts; see Errors and redirects.
Action requests
An action call is posted to the URL of the page on screen. The paramsSchemas along the page’s stack (its layouts’ and its own) run for that request too.
An action posted from /ja/talks/new runs with @k8ordo/i18n’s locale set to ja. The zod errors that parseForm from @k8ordo/form produces are in the page’s language too.
As in a guard.ts, cookies(), responseHeaders() and requestHeaders() work inside an action. See Request and cookies.
Posts from another site
A Server Action can be called from anywhere that can send a POST. So a POST to a page’s URL is accepted only when the host in its Origin header matches the host it was sent to (a route.ts is not checked). A POST with another host, or with no Origin, gets a 403 before any action runs.
For running behind a proxy, see Deployment.
With @k8ordo/form
src/lib/actions.ts'use server';
import { parseForm } from '@k8ordo/form/server';
import type { FormState } from '@k8ordo/form/server';
import { talkSchema } from './talk-schema';
import { insertTalk } from './talks.server';
export async function createTalk(
_previous: FormState,
formData: FormData,
): Promise<FormState> {
const parsed = parseForm(talkSchema, formData);
if (!parsed.success) return parsed.state;
await insertTalk(parsed.data);
return {};
}With @k8ordo/form, the fields’ constraints, the error messages and the server-side validation all come from one zod schema. The action validates with parseForm and returns parsed.state when validation fails.
The form side is covered in @k8ordo/form.