GioJSdocs
On this page

redirect

Answer a page action, getServerSideProps or a route handler with a redirect to another URL - 303 See Other by default, optionally with cookies.

app/contact/page.tsx
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

ParameterTypeDefaultDescription
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.
initnumber | RedirectInit303A status code, or an object with status and headers (below).

RedirectInit

FieldTypeDefaultDescription
statusnumber303301, 302, 303, 307 or 308. Anything else throws.
headersRecord<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 Location header, any headers you passed and an empty body. It is never cached, and goes out with Cache-Control: private, no-cache unless your headers set one - from a route.ts handler too - so a shared cache never stores a per-user guard's 301 or 308.
  • The default 303 makes the browser follow with a GET, so reloading the target never re-submits the form (the Post/Redirect/Get pattern). Use 307 or 308 only when the browser must repeat the original method and body.
  • A <GioForm> submission (it sends x-gio-form: 1) gets a 301, 302 or 303 as a 204 with the target in x-gio-redirect instead, 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 action ran first and returned headers (a flash cookie), they are sent along with a redirect that getServerSideProps answers afterwards.

Errors

redirect() throws a TypeError when it is called, not when the response is sent:

CauseMessage
Empty or non-string urlredirect() needs a non-empty URL
A control character (CR, LF, ...) in urlredirect() URL contains control characters
A status outside 301, 302, 303, 307, 308redirect() status must be 301, 302, 303, 307 or 308 (got 304)

isActionRedirect

ts
isActionRedirect(value: unknown): value is ActionRedirect

isActionRedirect() 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.

ts
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

app/contact/page.tsx
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:

lib/auth.server.ts
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;
}
app/dashboard/page.tsx
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

app/old-pricing/page.tsx
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.ts handler that returns or throws redirect() answers with the same redirect, so a guard like requireUserId() works there as well. Prefer it to Response.redirect(), which accepts only absolute URLs: a relative one such as /done makes it throw a TypeError, and the handler answers 500. 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.example is another host too):
    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;
    }
    The Redirecting guide uses the same check.
  • Do not swallow it. A try/catch around code that throws a redirect must rethrow it - see isActionRedirect.
  • The older object form still works. return { redirect: { destination: '/login', permanent: false } } from getServerSideProps answers 302 (301 when permanent). redirect() adds the status choice, the headers, and throwing.
  • Partial prerendering. On a cached PPR shell the 200 is already sent when getServerSideProps redirects, so the page finishes the redirect in the browser: a nonced location.replace(), or one reload that bypasses the shell when the redirect sets cookies.

Version history

VersionChanges
v0.1.0-beta.8Introduced redirect() and isActionRedirect(), for page actions, getServerSideProps and route.ts handlers (returned or thrown).