Actions & requests
A Server Action is a function the client can call that runs on the server, and this mode has somewhere for it to arrive. This page covers writing one, forms that work without JavaScript, ending an action with redirect(), and how a page reads the request.
Declaring one with 'use server'
Every export of a module that begins with 'use server' is a Server Action, and each must be async. A client component imports it and calls it; the call runs on the server.
// src/routes/_data/talks.server.ts
import 'server-only';
const talks: string[] = [];
export const saveTalk = (title: string): Promise<void> => {
talks.push(title);
return Promise.resolve();
};// src/routes/_parts/actions.ts
'use server';
import { saveTalk } from '../_data/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: 'a title is required' };
}
await saveTalk(title);
return {};
}useActionState takes the action and gives back the form's action, the previous result, and whether a submission is pending. The action receives the previous state and the FormData, and returns the next state.
// src/routes/_parts/talk-form.tsx
'use client';
import { useActionState } from 'react';
import { createTalk } from './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 ? null : <p role="alert">{state.error}</p>}
</form>
);
}One round trip, screen included
Calling an action re-renders the current page on the server and sends it back together with the return value, so the screen is up to date by the time the caller has its value — one round trip, not two.
Forms that work before JavaScript loads
React renders the fields that identify the action into the HTML. Posting them is an ordinary form submission: the server runs the action and answers with the same page's HTML, re-rendered. useActionState's result survives that trip, so the same component works with JavaScript and without it, never knowing which.
'use server' and server-only say different things
'use server' marks a function the client may call, which runs on the server; server-only marks a module the client may never reach. An actions module wants the first only. Keep secrets and database clients in a *.server.ts and import that from the action, as createTalk above does.
For the same reason an actions module is not named *.server.ts: being imported by the client is its purpose.
Ending with redirect()
redirect(to) from @k8ordo/server/runtime ends an action by sending the visitor elsewhere. It throws rather than returning, so the lines after it never run. A Server Component handing the action straight to <form action> needs no client component at all.
// src/routes/_parts/leave.ts
'use server';
import { redirect } from '@k8ordo/server/runtime';
import { saveTalk } from '../_data/talks.server';
export async function addAndLeave(formData: FormData): Promise<void> {
const title = formData.get('title');
if (typeof title === 'string' && title !== '') {
await saveTalk(title);
}
redirect('/products');
}// src/routes/page.tsx
import { addAndLeave } from './_parts/leave';
export default function HomePage() {
return (
<form action={addAndLeave}>
<input aria-label="title" name="title" />
<button type="submit">add and leave</button>
</form>
);
}A form posted without JavaScript is answered with a 303 and location, and the browser loads the target with a GET. A call from the client runtime is answered with a payload telling the router to navigate there. An action that redirected renders no page.
Because redirect() ends by throwing, calling it inside a try hands it to the catch. Call it outside the try.
There is no API for sending the visitor elsewhere while a page renders: redirect() only works inside a Server Action, and called during a render it is thrown as an ordinary error — error.tsx, or a 500. A URL that moved gets a redirect.ts. Errors & redirects
Reading the request
A page and a layout — and a not-found.tsx — receive request beside params and pathname: the headers, and the cookies parsed by name.
// src/routes/layout.tsx
import type { LayoutProps } from '@k8ordo/router';
export default function RootLayout({ children, request }: LayoutProps<'/'>) {
const theme = request.cookies.get('theme') === 'dark' ? 'dark' : 'light';
const language = request.headers.get('accept-language') ?? 'en';
return (
<html data-theme={theme} lang={language.split(',')[0]}>
<body>{children}</body>
</html>
);
}| Field | Type | What it holds |
|---|---|---|
headers | Headers | The request's headers |
cookies | ReadonlyMap<string, string> | The Cookie header parsed by name. The first of a repeated name wins, surrounding quotes are removed, and the value is URL-decoded — or left as sent when it cannot be. |
PageProps and LayoutProps carry request because the generated .k8ordo/register.gen.ts says this mode has one. RouteRequest from @k8ordo/server/runtime is its type, for a component further down that takes it as a prop. Do not hand it whole to a client component; pass the values it needs — Headers does not cross the boundary.
// src/routes/_parts/greeting.tsx
import type { RouteRequest } from '@k8ordo/server/runtime';
export function Greeting({ request }: { request: RouteRequest }) {
return <p>{request.cookies.get('name') ?? 'welcome'}</p>;
}Nothing lets a page write to the response — no status, no Set-Cookie — because a page is a render, and a render that answered the request would be a second handler.
The field exists only in this mode: under @k8ordo/static the generated Page and Layout types do not carry it, so a page that reads it fails to type-check. The search is not here either — it is @k8ordo/state's, read in the browser.
A POST from another origin is refused
A Server Action is reachable by name from anywhere that can make a POST — including another site's form, which the browser sends with your visitor's cookies. The handler accepts a POST only when its Origin header's host matches the host of the URL it is answering, and answers anything else with a 403. The check applies to every POST the handler receives, so a curl or webhook POST without an Origin header is refused as well.
Running behind a proxy is covered here. Run & deploy
With @k8ordo/form
@k8ordo/form derives a form's constraint attributes, its messages and its server-side validation from one zod schema. This is the guestbook from examples/server-basic: posted without JavaScript, it still comes back with per-field errors and the values entered.
// src/routes/_parts/guestbook-schema.ts
import * as z from 'zod/mini';
z.config(z.locales.en());
export const guestbookSchema = z.object({
name: z.string().check(z.minLength(1), z.maxLength(40)),
});// src/routes/_parts/guestbook.ts
'use server';
import { parseForm } from '@k8ordo/form/server';
import type { FormState } from '@k8ordo/form/server';
import { guestbookSchema } from './guestbook-schema';
const entries: string[] = [];
export async function sign(
_previous: FormState,
formData: FormData,
): Promise<FormState> {
const parsed = parseForm(guestbookSchema, formData);
if (!parsed.success) return parsed.state;
entries.push(parsed.data.name);
return {};
}// src/routes/_parts/guestbook-form.tsx
'use client';
import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';
import { sign } from './guestbook';
export function GuestbookForm({ fields }: { fields: FormFields<'name'> }) {
const [state, formAction] = useActionState(sign, {});
const form = useForm(fields, state);
const name = form.field('name');
return (
<form {...form.props} action={formAction}>
<input aria-label="name" {...name.input} />
<button type="submit">sign</button>
{name.error === undefined ? null : <p>{name.error}</p>}
</form>
);
}// src/routes/page.tsx
import { formFields } from '@k8ordo/form/server';
import { GuestbookForm } from './_parts/guestbook-form';
import { guestbookSchema } from './_parts/guestbook-schema';
const guestbookFields = formFields(guestbookSchema);
export default function HomePage() {
return <GuestbookForm fields={guestbookFields} />;
}The Server Component derives the attributes and messages from the schema and hands the result to the client as plain JSON props, so zod never reaches the browser. @k8ordo/form
What running buys over static
This is what the mode has over @k8ordo/static. If none of it is needed, @k8ordo/static renders the same application into files — the same grammar, the same boundaries, the same handler, called for each route at build time.
- A real 404 from the application for an unknown URL, rather than whatever the host says
- Parameterised routes with no list of values, so a changing catalogue needs no rebuild
- Server Actions a form can post to, and
redirect()from them - The request's headers and cookies, readable from a page