redirect
Answer a page action, getServerSideProps or a route handler with a redirect to another URL - 303 See Other by default, optionally with cookies.
import { redirect, type ActionArgs } from '@gio.js/core';
export async function action(req: ActionArgs) {
const form = await req.formData();
await saveMessage(String(form.get('message')));
return redirect('/contact/thanks');
}Reference
| Parameter | Type | Default | Description |
|---|---|---|---|
url (required) | string | - | Where the browser goes: a path (/thanks, /login?next=%2Fcart) or an absolute URL. It is sent as the Location header exactly as written. |
init | number | RedirectInit | 303 | A status code, or an object with status and headers (below). |
RedirectInit
| Field | Type | Default | Description |
|---|---|---|---|
status | number | 303 | 301, 302, 303, 307 or 308. Anything else throws. |
headers | Record<string, string | string[]> | - | Extra response headers, for example a set-cookie from commitSession(). Each set-cookie array entry is sent as its own header; other arrays are joined with , . |
Returns
An ActionRedirect object. It does nothing by itself: the renderer acts on it when an action, getServerSideProps or a route.ts method handler returns it or throws it. Throwing is what makes it useful in shared helpers - a requireUser() deep inside a loader can end the request.
Behavior
- The response has the chosen status, the
Locationheader, any headers you passed and an empty body. It is never cached, and goes out withCache-Control: private, no-cacheunless your headers set one - from aroute.tshandler too - so a shared cache never stores a per-user guard's301or308. - The default
303makes the browser follow with aGET, so reloading the target never re-submits the form (the Post/Redirect/Get pattern). Use307or308only when the browser must repeat the original method and body. - A
<GioForm>submission (it sendsx-gio-form: 1) gets a301,302or303as a204with the target inx-gio-redirectinstead, cookies included. The form then shows the target through the client router, or hands an off-site target to the browser. A plain HTML form post always receives the real redirect. - When a page
actionran first and returned headers (a flash cookie), they are sent along with a redirect thatgetServerSidePropsanswers afterwards.
Errors
redirect() throws a TypeError when it is called, not when the response is sent:
| Cause | Message |
|---|---|
Empty or non-string url | redirect() needs a non-empty URL |
A control character (CR, LF, ...) in url | redirect() URL contains control characters |
| A status outside 301, 302, 303, 307, 308 | redirect() status must be 301, 302, 303, 307 or 308 (got 304) |
isActionRedirect
isActionRedirect(value: unknown): value is ActionRedirectisActionRedirect() tells whether a value came from redirect(). Use it in a catch that must let redirects through - a thrown redirect is not an error. It checks a brand on the object rather than instanceof, because app modules load in their own module namespace and their copy of @gio.js/core may not be the renderer's. The brand is a Symbol.for() key, which JSON cannot carry: a handler that returns parsed request JSON as is never answers with a redirect, whatever the client sent. Before a redirect is sent its status and URL are checked again, and one redirect() would refuse answers 500 instead.
import { isActionRedirect } from '@gio.js/core';
try {
await checkout(cart); // may throw redirect('/login')
} catch (err) {
if (isActionRedirect(err)) throw err;
return { status: 422, data: { error: 'Payment failed - try again.' } };
}Examples
Redirect after a form post, with a cookie
import { redirect, type ActionArgs, type WithActionData } from '@gio.js/core';
import { GioForm } from '@gio.js/react';
import { sessions } from '../../lib/session.server.ts';
export async function action(req: ActionArgs) {
const form = await req.formData();
const email = String(form.get('email') ?? '');
if (!email.includes('@')) {
return { status: 422, data: { error: 'Enter a valid email address.' } };
}
const session = sessions.getSession(req);
session.set('flash', 'Thanks - we will get back to you.');
return redirect('/contact/thanks', {
headers: { 'set-cookie': sessions.commitSession(session) },
});
}
export default function Contact({ actionData }: WithActionData<typeof action>) {
return (
<GioForm>
<input name="email" type="email" />
{actionData?.error && <p role="alert">{actionData.error}</p>}
<button>Send</button>
</GioForm>
);
}A guard shared by pages and actions
Throw the redirect from a helper. It works the same from getServerSideProps, from an action and from a route.ts handler:
import { redirect, type GetServerSidePropsContext } from '@gio.js/core';
import { sessions } from './session.server.ts';
export function requireUserId(ctx: Pick<GetServerSidePropsContext, 'cookies' | 'path'>): string {
const userId = sessions.getSession(ctx).get('userId');
if (userId === undefined) {
throw redirect(`/login?next=${encodeURIComponent(ctx.path)}`, 302);
}
return userId;
}import type { GetServerSideProps, InferPageProps } from '@gio.js/core';
import { requireUserId } from '../../lib/auth.server.ts';
export const getServerSideProps = (async (ctx) => {
const userId = requireUserId(ctx);
return { props: { userId } };
}) satisfies GetServerSideProps;
export default function Dashboard({ userId }: InferPageProps<typeof getServerSideProps>) {
return <h1>Hello {userId}</h1>;
}GET /dashboard without a session answers 302 with Location: /login?next=%2Fdashboard.
A permanent redirect
import { redirect } from '@gio.js/core';
export function getServerSideProps() {
return redirect('/pricing', 308);
}
export default function OldPricing() {
return null;
}For a redirect that needs no code - a moved section, a renamed slug - prefer a [[redirects]] rule in gio.toml or a redirects entry in middleware.ts: the Rust server answers it before any Node code runs.
Good to know
- Route handlers too. A
route.tshandler that returns or throwsredirect()answers with the same redirect, so a guard likerequireUserId()works there as well. Prefer it toResponse.redirect(), which accepts only absolute URLs: a relative one such as/donemakes it throw aTypeError, and the handler answers500.redirect()sends a path as written. - Open redirects. The URL is used as given. Check a target that comes from the request (a
?next=parameter) before redirecting to it. Accept only a path that starts with/but not with//or/\(a browser reads both as another host), and that holds no control characters (a browser drops tabs and newlines, so/, a tab and/evil.exampleis another host too):The Redirecting guide uses the same check.ts/** Same-site paths only; `//evil.example`, `/\evil.example` and absolute URLs fall back to `/`. */ export function safeNext(value: string | undefined): string { // A browser drops tabs and newlines, and reads a leading // or /\ as another host. if (value === undefined || !value.startsWith('/') || /[\u0000-\u001f\u007f]/.test(value)) return '/'; return /^\/[/\\]/.test(value) ? '/' : value; } - Do not swallow it. A
try/catcharound code that throws a redirect must rethrow it - seeisActionRedirect. - The older object form still works.
return { redirect: { destination: '/login', permanent: false } }fromgetServerSidePropsanswers302(301whenpermanent).redirect()adds the status choice, the headers, and throwing. - Partial prerendering. On a cached PPR shell the
200is already sent whengetServerSidePropsredirects, so the page finishes the redirect in the browser: a noncedlocation.replace(), or one reload that bypasses the shell when the redirect sets cookies.
Related
- Forms and Mutations: Post/Redirect/Get
- Fetching Data: Redirects
- Redirecting - every way to redirect, compared
- action and getServerSideProps
- notFound
- GioForm
Version history
| Version | Changes |
|---|---|
v0.1.0-beta.8 | Introduced redirect() and isActionRedirect(), for page actions, getServerSideProps and route.ts handlers (returned or thrown). |