Forms

Write a handler, return a result and Goribu handles everything in between, so your forms feel fast and stay simple.

On this page

A complete form

This route accepts a name and returns a greeting:

src/routes/greet.tsx
import type { Handler } from "goribu/server";
import { Form } from "goribu";

type Result = { greeting: string };

// Show the current submission's state beside the field.
export default function GreetPage() {
  return (
    <Form<Result> method="post">
      {({ pending, errors, data }) => (
        <>
          <label htmlFor="name">Name</label>
          <input id="name" name="name" />
          <p>{errors?.name}</p>
          <button disabled={pending}>
            {pending ? "Sending…" : "Greet me"}
          </button>
          <p>{data?.greeting}</p>
        </>
      )}
    </Form>
  );
}

// Validate a native or enhanced submission before returning a result.
export const POST: Handler = async (req, res) => {
  const { name } = await req.body() as Record<string, unknown>;
  if (typeof name !== "string" || !name.trim()) {
    return res.invalid({ name: "Enter your name." });
  }
  return res.json({ greeting: `Hello, ${name.trim()}!` });
};
src/routes/greet.jsx
import { Form } from "goribu";

// Show the current submission's state beside the field.
export default function GreetPage() {
  return (
    <Form method="post">
      {({ pending, errors, data }) => (
        <>
          <label htmlFor="name">Name</label>
          <input id="name" name="name" />
          <p>{errors?.name}</p>
          <button disabled={pending}>
            {pending ? "Sending…" : "Greet me"}
          </button>
          <p>{data?.greeting}</p>
        </>
      )}
    </Form>
  );
}

// Validate a native or enhanced submission before returning a result.
export const POST = async (req, res) => {
  const { name } = await req.body();
  if (typeof name !== "string" || !name.trim()) {
    return res.invalid({ name: "Enter your name." });
  }
  return res.json({ greeting: `Hello, ${name.trim()}!` });
};
  • Method: method is required and it can be any HTTP verb.
  • Action: Defaults to the current URL. Use action="/another-route" to submit elsewhere.
  • Static pages: A mutation handler makes its page dynamic. To keep a page prerendered, put the handler in a separate route without a page component and target it with action.

Choose the result

Handler resultEnhanced submission
res.json(data)Stay on the page and expose data.
res.invalid(errors)Show errors, mark matching controls, and focus the first invalid field.
res.redirect(path)Navigate to the destination without repeating the mutation.
res.render(props)Render this route in place with fresh props and metadata.
  • Default choice: Use res.json for an in-place result and res.redirect when the next screen is another page, such as a list after saving.
  • Same-page renders: res.render() works only when the action is the displayed page. From a separate action route, redirect instead. If Goribu can't confirm the match, it reloads the page with a GET rather than repeating the write.
  • History: Add replace to Form to replace the current history entry instead of adding one.

Validation

Pass res.invalid() an object whose keys match control names, or a string for a form-level error:

Inside a handler
return res.invalid({
  name: "Enter a name.",
  root: "The request could not be saved.",
});
Inside a handler
return res.invalid({
  name: "Enter a name.",
  root: "The request could not be saved.",
});
  • Status: res.invalid() responds with 422.
  • Form-level errors: A string, or the root key, becomes errors.root.
  • Field matching: Goribu matches keys to inputs by name, sets aria-invalid, and focuses the first invalid input.
  • Rendering messages: Goribu doesn't display messages for you. Render each field error and errors.root.

Because Goribu sets aria-invalid on each matching input, a single CSS rule highlights every invalid field without extra code in your components:

src/styles/global.css
input[aria-invalid="true"] {
  border-color: crimson;
}

Always leave a place for form-level messages, even if you only return field errors today:

Form-level message
// Inside a Form render child, alongside your field messages:
{errors?.root && <p>{errors.root}</p>}
Form-level message
// Inside a Form render child, alongside your field messages:
{errors?.root && <p>{errors.root}</p>}

Always validate input and authorization in the handler. Browser constraints are a useful first check, not a replacement for server validation.

GET forms

Use GET for searches and filters:

Search form
import { Form } from "goribu";

// Put the search term in the URL so results can be shared.
export default function SearchForm() {
  return (
    <Form method="get" action="/search" replace>
      <label htmlFor="q">Search</label>
      <input id="q" name="q" />
      <button>Search</button>
    </Form>
  );
}
Search form
import { Form } from "goribu";

// Put the search term in the URL so results can be shared.
export default function SearchForm() {
  return (
    <Form method="get" action="/search" replace>
      <label htmlFor="q">Search</label>
      <input id="q" name="q" />
      <button>Search</button>
    </Form>
  );
}
  • Reading values: Use req.query.get("q") in the destination's GET handler.
  • Navigation, not results: GET navigates to the URL. It produces no data or errors.
  • Query string: Submitted fields replace any query already in action.

Methods and files

Each method calls the route export of the same name. This form updates a project with PUT:

src/routes/projects/[id].tsx
import type { Handler } from "goribu/server";
import { Form } from "goribu";

// `project` comes from this route's GET handler.
export default function ProjectPage({ project }) {
  return (
    <Form method="put">
      {({ pending, errors }) => (
        <>
          <label htmlFor="name">Name</label>
          <input id="name" name="name" defaultValue={project.name} />
          <p>{errors?.name}</p>
          <button disabled={pending}>Save</button>
        </>
      )}
    </Form>
  );
}

export const PUT: Handler = async (req, res) => {
  const { name } = await req.body() as Record<string, unknown>;
  if (typeof name !== "string" || !name.trim()) {
    return res.invalid({ name: "Enter a project name." });
  }

  // Save the new name for req.params.id here.
  return res.json({ saved: true });
};
src/routes/projects/[id].jsx
import { Form } from "goribu";

// `project` comes from this route's GET handler.
export default function ProjectPage({ project }) {
  return (
    <Form method="put">
      {({ pending, errors }) => (
        <>
          <label htmlFor="name">Name</label>
          <input id="name" name="name" defaultValue={project.name} />
          <p>{errors?.name}</p>
          <button disabled={pending}>Save</button>
        </>
      )}
    </Form>
  );
}

export const PUT = async (req, res) => {
  const { name } = await req.body();
  if (typeof name !== "string" || !name.trim()) {
    return res.invalid({ name: "Enter a project name." });
  }

  // Save the new name for req.params.id here.
  return res.json({ saved: true });
};
  • Methods: get, post, put, patch, and delete, in any case. Each calls the route's export of the same name with the real HTTP method. There is no POST override.
  • Encoding: Goribu picks it, switching to multipart when the form has a file input. Don't set encType.
  • Body shape: await req.body() returns an object of fields whatever the encoding.
  • Files: An upload is a File under its input's name, such as body.avatar.
  • Repeated names: Become arrays in submission order. Unchecked or missing fields are absent.
  • Validation: Check every value, and file size and type, on the server. See Reading the body.

Pending state and completion

Use reset to clear the fields after success. This component assumes the displayed page has a POST handler:

src/components/message-form.tsx
import { Form } from "goribu";

// Render this inside a page with a matching POST handler.
export default function MessageForm() {
  return (
    <Form method="post" reset>
      {({ pending, errors }) => (
        <>
          <label htmlFor="message">Message</label>
          <input id="message" name="message" />
          <p>{errors?.message}</p>
          <button disabled={pending}>Send</button>
        </>
      )}
    </Form>
  );
}
src/components/message-form.jsx
import { Form } from "goribu";

// Render this inside a page with a matching POST handler.
export default function MessageForm() {
  return (
    <Form method="post" reset>
      {({ pending, errors }) => (
        <>
          <label htmlFor="message">Message</label>
          <input id="message" name="message" />
          <p>{errors?.message}</p>
          <button disabled={pending}>Send</button>
        </>
      )}
    </Form>
  );
}
  • Render props: Children can be a function receiving { pending, errors, data }.
  • Pending: true while the request runs. Further submits from the same form are ignored automatically and old errors clear.
  • Reset: Clears fields after res.json or res.render, not after errors or redirects.
  • Callbacks: onSubmit can cancel with preventDefault(). onResult runs after the result is shown and before any redirect; check result.ok.
  • Ref: A ref points to the <form> element.
  • Cancellation: Unmounting the form or navigating away discards the pending result.