---
title: "Validate a form"
audience: "anyone whose form has to handle bad input"
description: "You'll build a sign-up form that rejects bad input, comes back with the message beside the field and leaves everything the visitor typed exactly where it was…"
snippet_caption: "app/schemas/signup.ts"
snippet: "export interface SignUp {\n  email: string;\n  display_name: string;\n}"
---

import { Island } from "@snapfire/fsr-client/react";
import { Install } from "@src/ui/Install";
import { Demo } from "@src/ui/Demo";
import { SignUpForm } from "@src/demos/signup/SignUpForm";

You'll build a sign-up form that rejects bad input, comes back with the message beside the field and leaves everything the visitor typed exactly where it was. It works with or without JavaScript and each path needs different things from you.

<Install captured="0.x" />

## Define the input

```ts title="app/schemas/signup.ts"
export interface SignUp {
  email: string;
  display_name: string;
}
```

Save that as `app/schemas/signup.ts`. The host checks a request against it before your body runs, so a missing field is refused without reaching your code. That check only covers the shape, so `""` passes because it is a string.

## Store the draft in the session

Checking what the values mean is up to you. Whatever a rejection says has to be somewhere the next render can read:

```ts title="app/schemas/session.ts"
export interface Session {
  draft: Record<string, string>;
  errors: Record<string, string>;
}

export const defaults: Session = {
  draft: {},
  errors: {},
};
```

That is `app/schemas/session.ts`. The draft is what the visitor typed and the errors are what was wrong with it. Both are stored per visitor on the server.

## Write the action

```ts title="app/routes/signup/actions.ts"
import { action } from "@snapfire/fsr";
import type { ActionCtx } from "@snapfire/fsr";
import type { SignUp } from "@schemas/signup";

export const signUp = action(async ({ input, session, services }: ActionCtx<SignUp>) => {
  const errors: Record<string, string> = {};
  if (!input.email.includes("@")) errors.email = "that does not look like an address";
  if (input.display_name.length < 2) errors.display_name = "two characters or more";

  if (Object.keys(errors).length > 0) {
    session.draft = { email: input.email, display_name: input.display_name };
    session.errors = errors;
    return { ok: false };
  }

  await services.accounts.create({ email: input.email, name: input.display_name });
  session.draft = {};
  session.errors = {};
  return { ok: true };
});
```

The action returns normally on invalid input instead of calling `fail`. The next section explains why.

## Choose between `fail` and the session

`fail("invalid", ...)` is for a request that should never have been made: an empty cart at checkout, an id that is not yours. It answers with the kind's status and a JavaScript caller catches an `ActionFailure`. A browser that posted the form natively is redirected back to the page with the failure waiting on it as the `action_failure` prop. The form page below shows it above the fields.

Only the render the redirect lands on gets the prop. A reload doesn't, so a stale message doesn't stay on the page.

Field-level validation wants something `fail` cannot carry: a message per field and the draft the visitor typed. One failure has one kind and one message. So the action above returns normally and writes both to the session. The page reads them from its loader.

Use `fail` when the whole request is wrong and one message covers it. Write to the session instead when a person has to fix several fields and keep what they typed.

## Read it in the loader

```ts title="app/routes/signup/page.loader.ts"
import type { Ctx } from "@snapfire/fsr";

export async function load({ session }: Ctx<"/signup">) {
  return {
    draft: session.draft,
    errors: session.errors,
  };
}
```

## Write the form

```tsx title="app/routes/signup/page.tsx"
import type { SignupProps } from "@generated/client";

type Failure = { kind: string; message: string };

export default function SignUpPage({
  draft,
  errors,
  csrf_token,
  action_failure,
}: SignupProps & { csrf_token?: string; action_failure?: Failure }) {
  return (
    <form method="post" action="/_sf/action/signup.signUp">
      <input type="hidden" name="_csrf" value={csrf_token ?? ""} />
      {action_failure ? <p className="form-error">{action_failure.message}</p> : null}

      <label>
        Email
        <input name="email" defaultValue={draft.email ?? ""} />
      </label>
      {errors.email ? <p className="field-error">{errors.email}</p> : null}

      <label>
        Display name
        <input name="display_name" defaultValue={draft.display_name ?? ""} />
      </label>
      {errors.display_name ? <p className="field-error">{errors.display_name}</p> : null}

      <button type="submit">Sign up</button>
    </form>
  );
}
```

`defaultValue` from the draft puts back what the visitor typed. The page is server-rendered, so the values are already in the HTML the browser receives.

`csrf_token` is a prop every page and layout may read. A form that anonymous visitors post needs `[session] csrf = "always"` in the configuration; without it there is no token to mint for them. The setting costs a session cookie only on a page that renders the token. Every other page sets no cookie, so it stays cacheable.

## Add JavaScript

To submit without a page load, the form needs a handler that runs in the browser. Move the form into a component of its own and give it an `onSubmit` that calls the action by id:

```tsx title="app/src/ui/SignUpForm.tsx"
import { actions } from "@generated/client";

type Props = {
  draft: Record<string, string>;
  errors: Record<string, string>;
  csrfToken: string;
};

export function SignUpForm({ draft, errors, csrfToken }: Props) {
  async function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const result = await actions.signup.signUp({
      email: String(data.get("email") ?? ""),
      display_name: String(data.get("display_name") ?? ""),
    });
    if (result.ok) location.assign("/welcome");
  }

  return (
    <form method="post" action="/_sf/action/signup.signUp" onSubmit={submit}>
      <input type="hidden" name="_csrf" value={csrfToken} />

      <label>
        Email
        <input name="email" defaultValue={draft.email ?? ""} />
      </label>
      {errors.email ? <p className="field-error">{errors.email}</p> : null}

      <label>
        Display name
        <input name="display_name" defaultValue={draft.display_name ?? ""} />
      </label>
      {errors.display_name ? <p className="field-error">{errors.display_name}</p> : null}

      <button type="submit">Sign up</button>
    </form>
  );
}
```

Then place it in the page as a browser island. The server still renders the form, so it works before the module loads and with JavaScript switched off:

```tsx title="app/routes/signup/page.tsx"
import { Island } from "@snapfire/fsr-client/react";
import type { SignupProps } from "@generated/client";
import { SignUpForm } from "@src/ui/SignUpForm";

type Failure = { kind: string; message: string };

export default function SignUpPage({
  draft,
  errors,
  csrf_token,
  action_failure,
}: SignupProps & { csrf_token?: string; action_failure?: Failure }) {
  return (
    <section>
      {action_failure ? <p className="form-error">{action_failure.message}</p> : null}
      <Island when="load">
        <SignUpForm draft={draft} errors={errors} csrfToken={csrf_token ?? ""} />
      </Island>
    </section>
  );
}
```

The action is unchanged. The errors it wrote to the session arrive on the next render either way, so the no-JavaScript path and the enhanced path agree about what is wrong with the form.

<Demo title="The sign-up form from this tutorial, as a browser island">
  {props.demo.welcome ? <p className="welcome">Signed up as {props.demo.welcome}.</p> : null}
  <Island when="load">
    <SignUpForm draft={props.demo.draft} errors={props.demo.errors} csrfToken={props.csrfToken} />
  </Island>
</Demo>

Type `nope` as the address and `x` as the name, then submit. Both messages come back and both fields keep what you typed. Reload and they're still there, because they're in your session.

## Clear the draft

A draft stays in the session until something clears it, so a visitor who comes back to `/signup` a week later still sees last week's typo. Handle it in the loader: either clear the draft once it's read or store it with the path it belongs to and ignore it elsewhere. Don't leave it to the page, since the page might not run.

Next up: [140. Stream a slow page](https://snapfirers.com/fsr/learn/140-stream-a-slow-page).
