Forms and Mutations
Page actions and <GioForm>: forms that work without JavaScript and feel instant with it.
A page can handle its own form posts. Export an async action from page.tsx: a POST to the page's URL runs it. Render the form with <GioForm> from @gio.js/react - a real <form method="post">, so it works before (or without) any JavaScript, and submits through the client router once the page has hydrated.
import { redirect, type ActionArgs, type WithActionData } from '@gio.js/core';
import { GioForm, useGioFormState } from '@gio.js/react';
export async function action(req: ActionArgs) {
const form = await req.formData();
const email = String(form.get('email') ?? '').trim();
const message = String(form.get('message') ?? '');
if (!email.includes('@')) {
return { status: 422, data: { errors: { email: 'Enter a valid email' }, values: { email, message } } };
}
await db.messages.insert({ email, message });
return redirect('/contact/thanks'); // 303 See Other
}
function SubmitButton() {
const { pending } = useGioFormState();
return <button type="submit" disabled={pending}>{pending ? 'Sending...' : 'Send'}</button>;
}
export default function Contact({ actionData }: WithActionData<typeof action>) {
const errors = actionData?.errors;
return (
<main>
<h1>Contact us</h1>
<GioForm>
<input name="email" defaultValue={actionData?.values.email} aria-invalid={errors?.email ? true : undefined} />
{errors?.email && <p role="alert">{errors.email}</p>}
<textarea name="message" defaultValue={actionData?.values.message} />
<SubmitButton />
</GioForm>
</main>
);
}Progressive enhancement
<GioForm> renders method="post" and no action attribute (unless you pass one), so the browser posts to the page's own URL. What happens next depends on whether the page has hydrated:
- Without JavaScript (or before the bundle loads): a normal form post. The browser follows the action's redirect, or shows the re-rendered page.
- Hydrated:
GioFormsends the same fields withfetch- the clicked button'sname/valueincluded,formAction/formEncTypehonored - and renders the answer like a soft navigation. A redirect's target is shown under its own URL; a re-render replaces the current history entry and keeps the scroll position. Layouts, the form itself and whatever the user typed survive, because the page is reconciled in the persistent React root instead of reloaded.
Plain <form method="post"> elements post to actions too - they just always do a full page load.
Writing an action
action(req) receives the same request object as a route handler - params, query, headers, cookies, ip, ... - plus formData(), which parses application/x-www-form-urlencoded and multipart/form-data bodies into a web-standard FormData (file fields are File objects). A body sent as anything else answers 415 unless you catch the error; a malformed one answers 400. Type the params with ActionArgs<{ id: string }>.
What the action returns decides the answer:
redirect(url)- a303 See Other. Pass a status (301, 302, 303, 307, 308) or{ status, headers }as the second argument.redirect()may also be thrown, from the action or anything it calls.{ status, data, headers }- an object whose keys aredataand optionallystatus/headers- re-renders the page withdataas itsactionDataprop, answering withstatus(default 200; 2xx or 4xx/5xx).- Any other value - re-renders the page with that value as
actionData, status 200. Returning nothing givesactionData: null. - A web
Response- sent as is (status, headers, body; binary bodies work).
notFound() answers 404 with the nearest not-found.tsx, and a thrown error answers 500 with the nearest error.tsx, exactly as during a render. getServerSideProps still runs for a re-render and sees the result as ctx.actionData (ctx.method is 'POST'); if it returns an actionData prop itself, that one wins.
Only POST runs an action - it is all an HTML form sends besides GET. PUT/PATCH/DELETE to a page answer 405 (with Allow: GET, HEAD, POST); give those their own route.ts. A page without an action answers every mutation with 405. A route.ts in the same folder that exports POST takes the POST; one that does not passes it on to the action (and a 405 from that folder lists both files' methods).
Headers the action returns with its data are sent whatever answers in the end: the re-rendered page, or a redirect, 404 or error page that replaces it. redirect() works in getServerSideProps too, returned or thrown - see Data Fetching.
Redirect after a change (Post/Redirect/Get)
After an action changes something, answer with redirect(). The 303 makes the browser GET the target, so reloading it never asks to resubmit the form and the back button behaves. Redirect to the same page to show the new state there:
export async function action(req: ActionArgs<{ id: string }>) {
const form = await req.formData();
if (form.get('intent') === 'delete') {
await db.todos.delete(String(form.get('todoId')));
} else {
await db.todos.insert({ list: req.params.id, title: String(form.get('title')) });
}
return redirect(`/lists/${req.params.id}`);
}
// Two buttons, one form: the clicked one's name/value is sent.
<GioForm>
<input type="hidden" name="todoId" value={todo.id} />
<button name="intent" value="delete">Delete</button>
</GioForm>A URL from user input (a ?next= parameter) must be checked before you redirect to it - otherwise the action is an open redirect. The Redirecting guide has a check you can copy, and every other way to redirect.
Redirects to other sites - a payment page, an identity provider - work from GioForm as well. Its requests carry x-gio-form: 1, and the server answers their 301/302/303 redirects with a 204 naming the target in x-gio-redirect (cookies included) instead: fetch would otherwise follow the redirect itself and fail the cross-origin check after the action had already run. GioForm then fetches a same-origin target (revalidating the HTTP cache) and hands any other to the browser. A 307/308, which repeats the POST, is still followed by fetch. Plain form posts always get the real redirect.
Validation errors
Return { status: 422, data } to show the form again with errors. The page re-renders with actionData - on the server for a plain post, swapped in place for GioForm - and the hydrated page receives the same prop. Send back the submitted values too and use them as defaultValues: without JavaScript the browser shows a fresh form.
WithActionData<typeof action, Props> adds an optional, typed actionData to your props; ActionData<typeof action> is the type alone. Both leave out the Response and redirect() branches, which never re-render. After a failed submission GioForm moves focus to the first field marked aria-invalid="true" (unless the page moved it); put error text in a role="alert" element so screen readers announce it.
GioForm
Every <form> prop passes through (className, encType, id, ...), plus:
action- where to post; default the current page.onSuccess(result)- after a 2xx answer, a redirect's target included.onError(result)- after any other answer (a 422 re-render too) or a failed request.resetOnSuccess- clear the fields after a successful submission that kept the form on screen.reloadDocument- never intercept: always a native, full page post (file downloads, for one).- children may be a function of the state:
{({ pending }) => ...}.
useGioFormState(), called anywhere inside the form, returns { pending, lastResult }. lastResult (also what the callbacks receive) has ok, status, url, redirected, the rendered page's data (its actionData), and response or error where they apply. While a submission is pending, further submits are ignored and the form has aria-busy="true" (style it with form[aria-busy="true"]).
Answers the router cannot render fall back to what the browser would do - but a submission is never sent twice when the action may already have run:
- A redirect to another site, or to something that is not a GioJS page (a file, JSON) - loaded as a full page (a
GET). The form stays pending while the page unloads, unless the target is a download (Content-Disposition: attachment) or the back/forward cache brings the page back. - A refusal that comes before the action runs - the server's 413 for a too-large upload, a 429 from its rate limiter (both marked
x-gio-refused: unread), a deployment change -onErrorruns (for the 413 and 429), then the form is submitted natively so the browser shows the real response. - Any other error that is not a GioJS page - a 500 when the action threw (and no
error.tsxrendered it), a 502/504 from a proxy or a timeout while the action may still be running, an errorResponsethe action returned (a 413 or 429 of its own included) - goes toonErrorwithresult.response. Nothing changes on screen and nothing is re-sent: show the failure fromlastResult. - A 2xx that is not a page (an action returning
Response.json(...)) - goes toonSuccessasresult.response; nothing changes on screen and nothing is sent twice. - A network failure -
onErrorwithstatus: 0; the form stays as it is.
File uploads
Set encType="multipart/form-data" - the browser needs it to send file contents, with or without JavaScript - and read the files from formData():
export async function action(req: ActionArgs) {
const file = (await req.formData()).get('avatar');
if (!(file instanceof File) || file.size === 0) {
return { status: 422, data: { error: 'Choose an image' } };
}
if (!['image/png', 'image/jpeg'].includes(file.type)) {
return { status: 422, data: { error: 'PNG or JPEG only' } };
}
await storage.put(`avatars/${crypto.randomUUID()}`, Buffer.from(await file.arrayBuffer()));
return redirect('/settings');
}
<GioForm encType="multipart/form-data">
<input type="file" name="avatar" accept="image/png,image/jpeg" />
<button>Upload</button>
</GioForm>The whole request body is limited by max_body_bytes in gio.toml's [server] section (default 2 MiB): the Rust server answers 413 Payload Too Large before the action runs. Raise it for larger uploads - the body is buffered in memory and handed to the worker in one piece (binary bodies base64-encoded, so above roughly 48 MiB a body is a 413 whatever the setting says). Send large media straight to object storage (a presigned URL) instead. Never trust file.name or file.type: they are whatever the client sent.
Security
- Cross-site form posts are refused with
403in the Rust server before the action runs (CSRF protection, on by default - see Security). Same-origin posts, with or without JavaScript, pass. An endpoint other sites post to on purpose (an OAuthform_postcallback) goes in[security.csrf] exempt. - Action answers and the pages they re-render are never cached, even on a page that exports
revalidate, and never replace the page's cached entry. actionand everything only it imports are left out of the client bundle, likegetServerSideProps. Still validate every field on the server: the action is a public endpoint anyone can post to.
Sessions and cookies
Read the session with getSession(req) and send cookies through the redirect's (or re-render's) headers - see Authentication:
import { redirect, type ActionArgs, type WithActionData } from '@gio.js/core';
import { GioForm } from '@gio.js/react';
import { sessions } from '../../lib/session.server.ts';
export async function action(req: ActionArgs) {
const form = await req.formData();
const user = await verifyPassword(String(form.get('email')), String(form.get('password')));
if (user === null) return { status: 401, data: { error: 'Wrong email or password' } };
const session = sessions.getSession(req);
session.set('userId', user.id);
return redirect('/dashboard', { headers: { 'set-cookie': sessions.commitSession(session) } });
}
export default function Login({ actionData }: WithActionData<typeof action>) {
return (
<GioForm>
{actionData?.error && <p role="alert">{actionData.error}</p>}
<input name="email" type="email" autoComplete="username" />
<input name="password" type="password" autoComplete="current-password" />
<button>Log in</button>
</GioForm>
);
}Fresh data after a mutation
GioForm drops the router's prefetched pages when it posts, and the page it shows next is fetched fresh. Pages cached in the Rust server (revalidate) keep serving their cached copy until it expires: purge them from the action with revalidatePath() / revalidateTag() from @gio.js/core (on-demand revalidation - see Caching) before redirecting.
import { redirect, revalidatePath, type ActionArgs } from '@gio.js/core';
export async function action(req: ActionArgs) {
await db.posts.publish(String((await req.formData()).get('postId')));
revalidatePath('/blog'); // the cached blog index shows it now
return redirect('/blog');
}Static export
Actions run in the server. A site deployed with gio export to a static host has no server to post to - point such forms at an external endpoint instead.
Testing
callRoute from @gio.js/core/testing posts to pages too: a URLSearchParams body is sent as a form.
import { callRoute } from '@gio.js/core/testing';
it('rejects an invalid email with 422', async () => {
const res = await callRoute('/contact', {
method: 'POST',
body: new URLSearchParams({ email: 'nope', message: 'hi' }),
});
expect(res.status).toBe(422);
expect(await res.text()).toContain('Enter a valid email');
});
it('redirects after sending', async () => {
const res = await callRoute('/contact', {
method: 'POST',
body: new URLSearchParams({ email: '[email protected]', message: 'hi' }),
});
expect(res.status).toBe(303);
expect(res.headers['location']).toBe('/contact/thanks');
});