Skip to content
This page View Markdown Open in ChatGPT Open in Claude

Forms

A form posts to its own page ; the page's action takes it. It works without any script, in every browser.

pages/contact.tsx
import { redirect } from 'am-ssr';
import type { PageConfig, PageContext, PageProps } from 'am-ssr';

export const config: PageConfig = { title: 'Contact' };

interface Sent {
    values: { email: string; message: string };
    errors: Record<string, string>;
}

export async function action(ctx: PageContext): Promise<Sent> {
    const values = { email: String(ctx.form.email ?? '').trim(), message: String(ctx.form.message ?? '').trim() };
    const errors: Record<string, string> = {};
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = 'An email address, please.';
    if (values.message.length < 10) errors.message = 'At least 10 characters.';
    if (Object.keys(errors).length > 0) {
        ctx.status(422);
        return { values, errors };
    }
    await ctx.db.saveSingleOrMultiple({ instance: 'mongodb', database: 'site', table: 'messages', saveData: { ...values, at: new Date() } });
    ctx.flash('success', 'Thanks, your message was sent.');
    redirect('/contact', 303);
}

export default function Contact({ actionData }: PageProps<undefined, Sent>) {
    const values = actionData?.values ?? { email: '', message: '' };
    const errors = actionData?.errors ?? {};
    return (
        <form method="post">
            <label>Email <input type="email" name="email" value={values.email} required /></label>
            {errors.email && <p class="error">{errors.email}</p>}
            <label>Message <textarea name="message" value={values.message} required></textarea></label>
            {errors.message && <p class="error">{errors.message}</p>}
            <button type="submit">Send</button>
        </form>
    );
}

How a post goes

  1. The browser posts the form to the address of the page.
  2. The server checks the CSRF token of the form (the form got it by itself) : a post without the right token is refused with 403.
  3. The middlewares run, then action(ctx). ctx.form holds the fields ; ctx.body the body as it came (a form, or JSON).
  4. What the action returns is actionData of the page, which renders again (with its loader) : the errors and what the visitor wrote.
  5. Or the action redirects : after a post which worked, a 303 sends the browser to a page it opens with GET. A reload does not post twice.

A page without an action answers 405 to a post.

The CSRF token

Every <form method="post"> of a page gets a hidden field _csrf with the token of its visitor : nothing to write. A form which posts to another site gets none.

A token is made from a secret kept in a cookie of the website (HttpOnly) and the key of the installation : another site can not read it, so it can not post in the name of a visitor.

A script sends the token as the header x-csrf-token. The server writes it into every page, with the base path of the website :

const { base, csrf } = window.__AM_SSR__;
await fetch(base + '/api/notes', {
    method: 'POST',
    headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
    body: JSON.stringify({ text: 'Hello' }),
});

With htmx (in libraries.json), one listener sends it with every request :

public/app.js
document.addEventListener('htmx:configRequest', (event) => {
    event.detail.headers['x-csrf-token'] = window.__AM_SSR__.csrf;
});

<meta name="csrf-token"> holds the token too, for the libraries which read it there.

An endpoint which other servers call (a webhook) says csrf: false and checks a secret of its own :

pages/hooks/payments.ts
import type { PageConfig, PageContext } from 'am-ssr';

export const config: PageConfig = { csrf: false };

export async function POST(ctx: PageContext) {
    const expected = await ctx.cache.get<string>('payments-webhook-secret');
    if (!expected || ctx.headers['x-webhook-secret'] !== expected) ctx.error(401, 'Not a webhook of ours.');
    // …
    return { received: true };
}

Check on the server

What the browser checks (required, type="email", maxlength) helps the visitor ; a request can skip all of it. Check every field in the action, and take only the fields you name : never save ctx.form as it came.

Messages shown once

ctx.flash('success', 'Saved.') keeps a message for the next page the visitor opens : a cookie of the website, sealed, for five minutes. The layout shows ctx.flashes ({ type, text }). A page which shows messages is never kept for other visitors.

Files

A form of several parts sends files :

<form method="post" enctype="multipart/form-data">
    <input type="file" name="photo" accept="image/*" />
    <button type="submit">Upload</button>
</form>
export async function action(ctx: PageContext) {
    const photo = ctx.files.find((file) => file.fieldname === 'photo');
    if (!photo || !photo.mimetype.startsWith('image/') || photo.size > 5_000_000) return { error: 'An image of 5 MB at most.' };
    // photo.path : where the server wrote it, in the folder of the uploads.
    // …
}

ctx.files lists { fieldname, originalname, mimetype, size, path, filename }. The limits are the ones of the uploads of the server.