A complete form
This route accepts a name and returns a greeting:
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()}!` });
};
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:
methodis 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 result | Enhanced 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.jsonfor an in-place result andres.redirectwhen 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
replacetoFormto 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:
return res.invalid({
name: "Enter a name.",
root: "The request could not be saved.",
});
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
rootkey, becomeserrors.root. - Field matching: Goribu matches keys to inputs by
name, setsaria-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:
input[aria-invalid="true"] {
border-color: crimson;
}
Always leave a place for form-level messages, even if you only return field errors today:
// Inside a Form render child, alongside your field messages:
{errors?.root && <p>{errors.root}</p>}
// 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:
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>
);
}
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
dataorerrors. - 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:
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 });
};
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, anddelete, 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
Fileunder its input's name, such asbody.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:
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>
);
}
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:
truewhile the request runs. Further submits from the same form are ignored automatically and old errors clear. - Reset: Clears fields after
res.jsonorres.render, not after errors or redirects. - Callbacks:
onSubmitcan cancel withpreventDefault().onResultruns after the result is shown and before any redirect; checkresult.ok. - Ref: A
refpoints to the<form>element. - Cancellation: Unmounting the form or navigating away discards the pending result.