Forms and login
A form posts to a handler: Form action={fn}. The handler checks the input, does the work and returns a value, a fail(…) or a redirect(…). The page shows the result and the data it reads is fetched again. This guide builds a contact form with validation errors, a login and a logout, a page only a logged-in visitor can open, and shows what the same forms do without JavaScript. The pages are React; the notes at the end say what differs in the other libraries. Every file below is in landing/samples/forms/ in the repository and is type-checked with the rest of the docs. The session is described in Sessions and security.
Set SESSION_PASSWORD to a secret of at least 32 characters, for example in .env: it seals the session cookie. A dev host on plain http other than localhost also needs cookie: { secure: false } in the session options, as Sessions and security says.
A schema
A handler can take an input, any Standard Schema object: Valibot, Zod and ArkType all are. The samples write the object by hand so that they need no package. schema(jsonSchema, check) wraps a check function.
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "silgi/server";
type Issue = { message: string; path?: PropertyKey[] };
type Check<T> = (value: unknown) => { value: T } | { issues: Issue[] };
export function schema<T>(jsonSchema: Record<string, unknown>, check: Check<T>): StandardSchemaV1<unknown, T> & StandardJSONSchemaV1<unknown, T> {
return {
"~standard": {
version: 1,
vendor: "docs",
validate: check,
jsonSchema: { input: () => jsonSchema, output: () => jsonSchema },
},
} as StandardSchemaV1<unknown, T> & StandardJSONSchemaV1<unknown, T>;
}
export const idInput = schema<{ id: string }>({ type: "object", properties: { id: { type: "string" } }, required: ["id"] }, (value) => {
const id = (value as { id?: unknown } | undefined)?.id;
return typeof id === "string" && id ? { value: { id } } : { issues: [{ message: "An id is required", path: ["id"] }] };
});
A contact form
The handler validates the body. When the check finds issues the handler does not run: the call fails with status 400 and the issues. When it passes, the handler gets the decoded value. It returns a redirect, so that a reload of the result page does not send the message again. Like every handler it is exported from server/index.ts.
import { redirect } from "silgi/app";
import { defineHandler } from "silgi/server";
import { schema } from "./utils/schema.ts";
const contact = schema<{ email: string; message: string }>(
{ type: "object", properties: { email: { type: "string" }, message: { type: "string" } }, required: ["email", "message"] },
(value) => {
const { email, message } = value as { email?: unknown; message?: unknown };
const issues = [];
if (typeof email !== "string" || !email.includes("@")) issues.push({ message: "Enter an email address", path: ["email"] });
if (typeof message !== "string" || message.length < 10) issues.push({ message: "Write at least 10 characters", path: ["message"] });
return issues.length ? { issues } : { value: { email: email as string, message: message as string } };
},
);
export const sendContact = defineHandler({
input: contact,
handler({ email }, { event }) {
event.log.info("contact message", { email });
return redirect("/contact?sent");
},
});
The page spreads fields.<name>.as(type) of useForm(fn) on each input. That writes name, type, the value the visitor typed when the post failed, and aria-invalid="true" while the field has an issue. fields.<name>.issues() lists the issues of one field.
import { Form, useForm, useRoute } from "@silgi/react";
import { sendContact } from "#server";
export default function Contact() {
const { fields, pending } = useForm(sendContact);
const route = useRoute();
if ("sent" in route.query) return <p>Thanks, we will write back.</p>;
return (
<Form action={sendContact}>
<label>
Email <input {...fields.email.as("email")} />
</label>
{fields.email.issues().map((issue) => (
<p role="alert" key={issue.message}>{issue.message}</p>
))}
<label>
Message <textarea {...fields.message.as("textarea")} />
</label>
{fields.message.issues().map((issue) => (
<p role="alert" key={issue.message}>{issue.message}</p>
))}
<button disabled={pending}>Send</button>
</Form>
);
}
With JavaScript on, Form takes the submit over: one request to the handler, no new document, the page keeps its state and the focus moves to the first field with an issue. With JavaScript off, the browser posts to the page, and the answer is the same page, rendered again with the issues, status 400, and the typed values in the fields.
The Form component
Every adapter exports Form. With a handler as action it renders <form method="post" action="<this page>?_fn=<id>" data-silgi-form>, so it works with and without JavaScript. With a path or a route location as action, or none, it renders a GET form that navigates with the fields as the query. A POST form to anything else is not a Form: write a plain <form>.
| Prop | Default | |
|---|---|---|
action | the page on screen | a handler, which must not be method: "GET", or an app path or route location for a GET form |
encType | urlencoded | "multipart/form-data" for files |
instance | a key for the store of this form's runs: useAction(fn, { instance }) is the same store, so one handler can sit in a list of forms | |
replace | false | replace the history entry (a GET form) |
reset | false | reset the form after a success |
reloadDocument | false | leave the form to the browser |
refresh | true | false leaves the page's data alone after a success |
restore | true | false keeps typed values out of a no-JavaScript failure page |
onSubmit | called before the request, with the FormData; cancel() stops it |
The Form reference has the per-library shapes. A plain <form> in a page script or an html page gets the same behaviour from enhance(form, options) of silgi/app.
A form is left to the browser when it has data-silgi-reload, a target other than _self, method="dialog", enctype="text/plain", a GET with multipart/form-data, or another origin as its action.
What a handler returns to a form
| Return | The page gets |
|---|---|
redirect(to), returned or thrown | a navigation to to. Without JavaScript a 303 |
fail(status, data) | useAction(fn).failure holds { status, data }; the status must be 400 to 499 (else RangeError). A failure is not an error and refreshes nothing |
| any other value, or nothing | the value in useAction(fn).state |
a failed input check | status 400 and useAction(fn).error with data: { issues, values }; useForm(fn).fields reads it |
| a thrown error | the error with the error's status, masked as every error (Errors); useAction(fn).error holds it |
redirect is the usual answer to a change the visitor must not repeat (post, redirect, get), so redirect("/todos") from a post on /todos is the normal pattern. A target on another origin needs redirect(to, { external: true }).
Reading the result
useAction(fn) returns { run, state, failure, pending, error, reset }: the last value, the last fail(), whether a call is running (the refresh after it included), the last error, and run(input) to call the handler from code. A Form action={fn} and a useAction(fn) call share one store, so the form's result is readable anywhere in the app. useForm(fn) returns { fields, pending, result, for(key) }. The page below shows a failure message, the pending state and the field helpers:
import { defineHandler, fail } from "silgi/server";
import { db } from "./utils/todos.ts";
import { idInput, schema } from "./utils/schema.ts";
const newTodo = schema<{ title: string }>(
{ type: "object", properties: { title: { type: "string", minLength: 1 } }, required: ["title"] },
(value) => {
const title = (value as { title?: unknown } | undefined)?.title;
return typeof title === "string" && title ? { value: { title } } : { issues: [{ message: "A todo needs a title", path: ["title"] }] };
},
);
export const listTodos = defineHandler({
method: "GET",
handler: () => db.all(),
});
export const addTodo = defineHandler({
input: newTodo,
handler: ({ title }) => db.add(title),
});
export const removeTodo = defineHandler({
input: idInput,
handler({ id }) {
if (!db.remove(Number(id))) return fail(404, { message: "That todo is already gone" });
return { removed: Number(id) };
},
});
import { Form, useAction, useData, useForm } from "@silgi/react";
import { addTodo, removeTodo } from "#server";
export default function Todos() {
const { todos } = useData<{ todos: Array<{ id: number; title: string }> }>();
const form = useForm(addTodo);
const removal = useAction(removeTodo);
return (
<main>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.title}
<Form action={removeTodo} instance={todo.id}>
<input type="hidden" name="id" value={todo.id} />
<button>Remove</button>
</Form>
</li>
))}
</ul>
<Form action={addTodo} reset>
<input {...form.fields.title.as("text")} />
{form.fields.title.issues().map((issue) => (
<p role="alert" key={issue.message}>{issue.message}</p>
))}
<button disabled={form.pending}>Add</button>
</Form>
{form.pending ? <p>Saving…</p> : null}
{removal.failure ? <p role="alert">{(removal.failure.data as { message: string }).message}</p> : null}
</main>
);
}
The load that gives the page its list is in todos.load.ts; after a success the page's load files and live useAsyncData entries are fetched again, so the list shows the new todo.
import type { Load } from "silgi/app";
import { listTodos } from "#server";
export const load = (async () => ({ todos: await listTodos() })) satisfies Load;
Validation
With input, the body is decoded, validated with the schema's ~standard.validate, and the handler is called only when it passes. A form body goes through formToObject; a JSON body (a call from code) is used as it is. On issues the call fails with status 400 and
{
issues: Array<{ path: Array<string | number>; message: string }>,
values: Record<string, unknown>,
}
as data. values is the decoded body, minus every field whose name (after its n: or b: prefix) starts with _, every top-level JSON key that starts with _, and every File. A password sent as _password therefore never comes back. A validator that throws gives one issue with an empty path: the message of a TypeError or RangeError, else Validation failed outside dev; a thrown SilgiError is not turned into an issue, it is the handler's error. An input that is not a Standard Schema throws TypeError when the handler is defined.
The form codec
A form body is flat; formToObject makes it an object in one pass:
| Input | Output |
|---|---|
a.b=1 | { a: { b: "1" } } |
a[0]=x&a[1]=y, a[0].b=1 | { a: ["x", "y"] }, { a: [{ b: "1" }] } |
tag=a&tag=b | { tag: ["a", "b"] } (one occurrence stays a string) |
n:qty=2 | { qty: 2 } (a value that is not a number gives NaN for the schema to refuse) |
b:agree=on, b:agree=true, b:agree=no | { agree: true }, { agree: true }, { agree: false } |
title= | { title: undefined } |
| a file part | the File |
a[1000]=x | 400 Form field index too large in "a[1000]" |
Use formToObject yourself in a route without a schema, and readValidatedForm(event, schema) from silgi/server in a handler that reads the event: it reads the form, decodes it, validates it, and throws a 400 Validation failed with data: { issues } as readValidatedBody does.
Field helpers
useForm(fn) gives the form of one handler: { fields, pending, result, for(key) }. The page above uses it for the add form.
fields.title.as("text")returnsname,type,valueandaria-invalid="true"while there are issues, as a plain object to spread in JSX, a Vuev-bindor a Svelte{...}. In React it returnsdefaultValueanddefaultChecked, because React treats avaluewithoutonChangeas read-only.astakes any input type,"select"and"textarea"included;checkboxandradiotake the value as a second argument.fields.title.issues()lists the issues whose path is this field's;allIssues()also those below it.fields.info.heightandfields.tags[0]follow the path.fields.title.value()andset(value)read and write the value thatas()shows.- The names carry the codec prefix.
as("number")andas("range")post as numbers (n:), and acheckboxwithout a value as a boolean (b:). The build runs no user code for a form and cannot read the schema, soas("text")over a numeric field posts text: usez.coerce.number()or the like in the schema. form.pendingis true while a run of this handler is in flight. A page with several forms of one handler gives each form a key withinstanceand readsform.for(key).pending,.resultand.fields.for("")throws.- A schema field named
as,issues,allIssues,valueorsetis not reachable throughfields; rename it.
Keeping values and focus
After a failure with values, the typed values come back into the form. With JavaScript the form is not replaced, so nothing is lost; the first field with aria-invalid="true" takes the focus once the issues are shown, unless the visitor moved the focus. Without JavaScript the server rewrites the page: inside each form with data-silgi-form that posted this handler and has no data-silgi-restore="false", a text input gets value, a checkbox or radio checked, a textarea its content, a select the matching selected. It never writes into password, file or hidden inputs, and it leaves a control that already has a value alone, so a field made with as() is not written twice. restore={false} on Form turns this off.
Login and logout
Login is a handler too. It validates the name, then session.regenerate({ data }) gives the session a new id and sets the user in one step. The session helper is a few lines:
import { createError, useSession } from "silgi/server";
import type { SilgiEvent } from "silgi/server";
type UserSession = { user?: string };
const password = process.env.SESSION_PASSWORD as string;
export const getSession = (event: SilgiEvent) => useSession<UserSession>(event, { password, maxAge: 60 * 60 * 24 * 7 });
export async function requireUser(event: SilgiEvent): Promise<string> {
const session = await getSession(event);
if (!session.data.user) throw createError({ status: 401, message: "Sign in first" });
return session.data.user;
}
logIn keeps the visitor's next target, which the page sends in a hidden field, but only when it is an app path: a value like //example.com would otherwise send the visitor to another site, and redirect refuses it with a 500. logOut is a second handler, so there is no action name to pick. whoami is a GET handler the plugin below calls.
import { redirect } from "silgi/app";
import { defineHandler } from "silgi/server";
import { getSession } from "./utils/auth.ts";
import { schema } from "./utils/schema.ts";
const login = schema<{ name: string; next?: string }>(
{ type: "object", properties: { name: { type: "string", minLength: 1 }, next: { type: "string" } }, required: ["name"] },
(value) => {
const { name, next } = value as { name?: unknown; next?: unknown };
if (typeof name !== "string" || !name) return { issues: [{ message: "Enter a name", path: ["name"] }] };
return { value: typeof next === "string" ? { name, next } : { name } };
},
);
const appPath = (next: string | undefined) => (next && /^\/(?![/\\])/.test(next) ? next : "/account");
export const logIn = defineHandler({
input: login,
async handler({ name, next }, { event }) {
const session = await getSession(event);
await session.regenerate({ data: { user: name } });
return redirect(appPath(next));
},
});
export const logOut = defineHandler({
async handler(_input: unknown, { event }) {
const session = await getSession(event);
await session.clear();
return redirect("/login");
},
});
export const whoami = defineHandler({
method: "GET",
async handler(_input: unknown, { event }) {
const session = await getSession(event);
return { user: session.data.user ?? null };
},
});
Login and logout change what every page shows, so the forms use reloadDocument. The browser posts the form itself and follows the 303, and the next page is rendered on the server with the new session. That also re-runs the app plugin below.
import { Form, useForm, useRoute, useState as useSharedState } from "@silgi/react";
import { logIn, logOut } from "#server";
export default function Login() {
const [user] = useSharedState<string | null>("user", null);
const { fields } = useForm(logIn);
const next = useRoute().query.next;
if (user) {
return (
<Form action={logOut} reloadDocument>
<p>Signed in as {user}.</p>
<button>Log out</button>
</Form>
);
}
return (
<Form action={logIn} reloadDocument>
<input {...fields.name.as("text")} placeholder="Name" />
{typeof next === "string" ? <input type="hidden" name="next" value={next} /> : null}
{fields.name.issues().map((issue) => (
<p role="alert" key={issue.message}>{issue.message}</p>
))}
<button>Log in</button>
</Form>
);
}
Who is logged in
Route middleware also runs in the browser, where the session cookie is HttpOnly and cannot be read, so it asks the shared state instead. An app plugin that runs on the server only, app/plugins/user.server.ts, calls whoami while the page renders, with the visitor's cookie, and puts the answer in the state. The state travels with the page to the browser:
import { definePlugin } from "silgi/app";
import { whoami } from "#server";
export default definePlugin(async (app) => {
const { user } = await whoami();
app.state.set("user", user);
});
The auth middleware reads ctx.state.get("user") and redirects to /login when it is empty:
import { defineRouteMiddleware, redirect } from "silgi/app";
export default defineRouteMiddleware((to, from, ctx) => {
if (!ctx.state.get("user")) return redirect(`/login?next=${encodeURIComponent(to.fullPath)}`);
});
A page names it in its meta:
import { useState as useSharedState } from "@silgi/react";
import type { PageMeta } from "silgi/app";
export const meta: PageMeta = { middleware: ["auth"] };
export default function Account() {
const [user] = useSharedState<string | null>("user", null);
return <h1>Hello, {user}</h1>;
}
The plugin runs on the server only. It does not run after a client navigation, which is why the login and logout forms load the next page in full. A reload of /account renders on the server again, where the plugin finds the session cookie.
This middleware steers the visitor; it guards nothing. A user can skip it, and the data is behind the handlers. Put the check where the data is: Sessions and security.
Without JavaScript
Nothing in this guide needs a script. A form that Form renders is a native form, so a visitor with JavaScript off, a script that failed to load, or a form submitted before hydration all use the same handler. The browser posts to <page>?_fn=<id>, the handler runs with the decoded fields as its input, and the answer is the page itself, rendered with the result in useAction(fn) and useForm(fn). The order of a POST to a page path:
- The origin check and server middleware run as for any request.
- A
POSTwithout_fnanswers 405 withAllow: GET, HEAD; an unknown id answers 404; the id of aGEThandler answers 405. - The content type must be a form (
application/x-www-form-urlencodedormultipart/form-data); any other answers 415. - The body is read within the handler's
formlimits,formToObjectdecodes it, and the handler runs withmiddlewareandinput. - The result is answered as in the table above: a redirect as 303, a
fail()as the page with the failure's status, a failedinputas the page with 400, a value as the page with 200, a thrown error as the page with the error's status. - The page renders as for a
GET: route middleware, then theloadfiles, which run after the handler, so the page shows fresh data.
A success that returns a value (not a redirect) answers the page with status 200. A reload posts the form again, as in every framework; return a redirect after a change the visitor must not repeat. The typed values come back into the fields, except password, file and hidden inputs and every field whose name starts with _. A handler needs a server: a static site has none, and its forms have nothing to post to.
Pending state and optimistic UI
useFormStatus() gives { pending, data, action } for the Form the component is rendered in; useForm(fn).pending and useAction(fn).pending give it for a handler, and the navigator sets data-pending on a data-silgi-form form while it posts, which CSS can use (form[data-pending] button { opacity: .5 }) and which is the only status an html page has, since it has no useFormStatus. useNavigation() has state: "idle" | "loading" for navigations and revalidation: "loading" while refresh() runs.
AddButton below is rendered inside the Form, so useFormStatus() is the status of that form. The handler it posts to lists the auth middleware of Handlers:
import { defineHandler } from "silgi/server";
import { auth } from "./guards/auth.ts";
import { schema } from "./utils/schema.ts";
const newNote = schema<{ text: string }>(
{ type: "object", properties: { text: { type: "string", minLength: 1 } }, required: ["text"] },
(value) => {
const text = (value as { text?: unknown } | undefined)?.text;
return typeof text === "string" && text ? { value: { text } } : { issues: [{ message: "A note needs text", path: ["text"] }] };
},
);
export const addNote = defineHandler({
input: newNote,
middleware: [auth],
handler({ text }, { context, clientContext }) {
return { author: context.user, locale: String(clientContext.locale ?? "?"), text };
},
});
import { Form, useAction, useFormStatus } from "@silgi/react";
import { addNote } from "#server";
function AddButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>Add</button>;
}
export default function Notes() {
const add = useAction(addNote);
return (
<Form action={addNote} reset>
<input name="text" />
<AddButton />
{add.state ? <p>{`Added: ${add.state.text}`}</p> : null}
{add.error ? <p role="alert">{add.error.message}</p> : null}
</Form>
);
}
useOptimistic(value, reduce) returns reduce(value, pending) while a handler call made from a Form or a useAction is running, and value otherwise. pending lists the running calls, oldest first, each { fn, instance?, input, formData? }. An entry leaves the list when the call has settled and the refreshed data is on screen, so a failed row disappears. React runs it over React's own useOptimistic; html has no useOptimistic, and its page scripts read pendingRuns(app) from silgi/adapter.
import { Form, useAction, useData, useOptimistic } from "@silgi/react";
import { addTodo } from "#server";
export default function Quick() {
const { todos } = useData<{ todos: Array<{ id: number; title: string }> }>();
const add = useAction(addTodo);
const shown = useOptimistic(todos, (current, pending) => [
...current,
...pending.flatMap((run) => {
const title = run.formData?.get("title") ?? (run.input as { title?: string } | undefined)?.title;
return typeof title === "string" ? [{ id: -1, title: `${title} (saving)` }] : [];
}),
]);
return (
<main>
<ul>
{shown.map((todo, at) => (
<li key={at}>{todo.title}</li>
))}
</ul>
<Form action={addTodo} reset>
<input name="title" />
<button disabled={add.pending}>Add</button>
</Form>
{add.error ? <p role="alert">{add.error.message}</p> : null}
<button type="button" onClick={() => void add.run({ title: "From code" })}>
Add from code
</button>
</main>
);
}
addTodo is the handler of the todo list above and quick.load.ts returns the same list, so the saving row is replaced by the real one when the refresh ends.
Uploads
A handler's form option sets how a multipart body is read, with Silgi's streaming parser:
| Option | Default | |
|---|---|---|
maxBodySize | server.maxBodySize (1 MiB) | the whole body |
maxFileSize | maxBodySize | one file |
maxFiles | 10 | files in the form |
onFile(file) | gets each file as a stream (field, name, type, stream); returns a File, a string, or nothing to drop the field |
Without onFile, file parts are collected as Files. With it, the stream is read from the request as it is pulled, so a large file does not sit in memory; what you leave unread is drained and still counts against the limits. A body above maxBodySize answers 413 Request body is too large before any byte is parsed; a file above maxFileSize answers 413 File "photos" is larger than 5242880 bytes; more files than maxFiles answers 413 More than 3 files in the form; more than 1000 text fields answers 413 More than 1000 fields in the form. A malformed body answers 400 Invalid multipart body; the handler does not run. The same parser is readMultipart(event, options) for code that reads the event.
import { defineHandler } from "silgi/server";
export const checksum = defineHandler({
async handler({ file }: { file: File }) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
},
});
export const report = defineHandler({
handler: () => new File(["id,title\n"], "todos.csv", { type: "text/csv" }),
});
export const upload = defineHandler({
form: { maxBodySize: 16 * 1024 * 1024, maxFileSize: 5 * 1024 * 1024, maxFiles: 3 },
async handler({ photos }: { photos: File[] }) {
return { names: photos.map((photo) => photo.name), bytes: photos.reduce((sum, photo) => sum + photo.size, 0) };
},
});
import { Form, useAction } from "@silgi/react";
import { upload } from "#server";
export default function Upload() {
const { state } = useAction(upload);
return (
<Form action={upload} encType="multipart/form-data">
<input type="file" name="photos" multiple />
<button>Upload</button>
{state ? <p>{`${state.names.length} files, ${state.bytes} bytes`}</p> : null}
</Form>
);
}
The upload handler reads each file as a File and keeps no copy. The body limit above maxFileSize matters: a file can never be larger than the body that carries it. The config of the sample sets server.maxBodySize to 2 MiB for everything else:
import type { SilgiConfig } from "silgi";
export default {
framework: "react",
server: {
maxBodySize: 2 * 1024 * 1024,
},
build: {
importProtection: { client: { deny: ["pg", /^@internal\//] } },
},
} satisfies SilgiConfig;
Cross-site posts
A handler is a public endpoint, and a form post to it is a POST anyone can send. Silgi refuses a cross-site form post before it reaches the handler: the origin check answers 403 to a POST with no content type, application/x-www-form-urlencoded, multipart/form-data or text/plain that another site sent, and the 415 rule refuses any content type other than the two form types and application/json. csrf() from Sessions and security still works inside a handler when you want a token as well; call it with event from the handler's context.
Things to keep in mind
- A handler runs for anyone who can send a
POSTto its page or to its URL. Check the session inside it or in itsmiddleware, not only in a route middleware that guards the page. - A page whose handler sets a cookie, like the login, must not be cached or prerendered.
- Keep per-user data out of the shared state unless the visitor may read it: the state is part of the page.
- A
POSTroute at the page's own path takes the post first, so such a page cannot take?_fn=.
Other libraries
The handlers, the plugin and the middleware are the same in every library. Only the page differs:
- Preact: the same code, with the imports from
@silgi/preact. - Vue:
useFormreturns a reactive object, so templates readform.fields.email.issues()directly and spreadv-bind="form.fields.email.as('email')";Formis a component with a default slot. - Svelte:
useFormreturns getters, andFormtakes its content as achildrensnippet. - Solid:
pendingandresultofuseFormare accessors, soform.pending(). - html: pages are strings.
Form({ action: fn }, "<input name='email'>")returns the form;useFormreturns an object at render time, and the page builds the attributes from it. A hydrated html page runs no component code, so a handler form there is always a native post that the browser follows.