GioJSdocs
On this page

Redirecting

Every way to send a visitor to another URL in GioJS - from a page action, getServerSideProps, a route handler, gio.toml rules, middleware.ts and guards - and which status code each one sends.

Pick the place by when you know where the visitor should go. Rules that depend only on the URL belong in gio.toml or middleware.ts: the Rust server answers them before any of your code runs. Decisions that need data - is this visitor signed in, does this post still exist - belong in your server code.

APIWhereUse it forDefault status
redirect()Page actionAfter a form post (Post/Redirect/Get)303
redirect() or { redirect }getServerSidePropsBefore rendering: auth checks, moved records303; 302 or 301 for the object form
redirect() or a redirect Responseroute.tsAPI endpoints, short links, OAuth hops303 for redirect(); yours for a Response
[[redirects]]gio.tomlMoved URLs known at deploy time302
redirectsmiddleware.tsThe same rules, typed and in TypeScript302
redirect_to[[guards]]Sending signed-out visitors to a login page302 (fixed)
router.push, navigate()BrowserAfter a client-side event-

redirect() in a page action

A page's action handles the POST of its form. Return redirect(url) from @gio.js/core when it succeeds. The default 303 See Other makes the browser load the target with a GET, so reloading the next page never submits the form again:

app/contact/page.tsx
import React from 'react';
import { redirect, type ActionArgs } from '@gio.js/core';
import { saveMessage } from '../../lib/messages';

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.' } };
  }
  await saveMessage(email, String(form.get('message') ?? ''));
  return redirect('/contact/thanks');
}

export default function Contact({ actionData }: { actionData?: { error?: string } }): React.JSX.Element {
  return (
    <form method="post">
      <input name="email" type="email" />
      <textarea name="message" />
      {actionData?.error !== undefined && <p role="alert">{actionData.error}</p>}
      <button type="submit">Send</button>
    </form>
  );
}

The second argument is a status or { status, headers }. Headers go out with the redirect - a session cookie after a login, an expired one after a logout:

ts
return redirect('/dashboard', {
  headers: { 'set-cookie': sessions.commitSession(session) },
});

With <GioForm> the same action runs, and the hydrated form follows the redirect through the client router - no full page load, and the browser history records the URL the redirect landed on. A redirect to another site is followed with a full navigation.

redirect() in getServerSideProps

getServerSideProps runs before the page renders, so a redirect there replaces the page entirely. Return redirect(), or throw it from any helper it calls - one guard function then serves pages and actions alike:

lib/auth.server.ts
import { createSessionStorage, redirect } from '@gio.js/core';

export const sessions = createSessionStorage<{ userId: string }>();

export function requireUser(cookies: Record<string, string>): string {
  const userId = sessions.getSession({ cookies }).get('userId');
  if (userId === undefined) throw redirect('/login', 307);
  return userId;
}
app/account/page.tsx
import { redirect, type GsspContext } from '@gio.js/core';

export async function getServerSideProps(ctx: GsspContext) {
  const user = ctx.cookies['user'];
  if (user === undefined) {
    // Remember where the visitor was going, as a path on this site.
    return redirect(`/login?next=${encodeURIComponent(ctx.path)}`);
  }
  return { props: { user } };
}

The Next.js-style object form also works, and maps permanent to 301 or 302:

app/moved/page.tsx
export async function getServerSideProps() {
  return { redirect: { destination: '/storefront', permanent: true } }; // 301
}

Redirect answers are never stored in the page cache, even on a page that exports revalidate, and carry Cache-Control: private, no-cache unless their headers set one - a 301 is otherwise cacheable by default, and a CDN would replay one visitor's redirect to everyone. On a page cached with shell = 'cache', the shell's 200 is already sent when getServerSideProps answers, so the page sends the visitor on with location.replace() (or reloads once to get the real response); a guard avoids that flash. See Caching Layers.

Redirect in a route handler

A route.ts handler may return or throw redirect(), as an action does: the answer has its status (303 unless you pick another), its headers and a Location header with the URL as written, relative or absolute. A web Response works too, but Response.redirect() accepts only an absolute URL: on a path like / it throws, and the handler answers 500.

app/api/go/route.ts
import { redirect, type GioRequest } from '@gio.js/core';

const LINKS: Record<string, string> = {
  docs: 'https://giojs.com/docs',
  home: '/',
};

// /api/go?to=docs - a short-link endpoint.
export function GET(req: GioRequest) {
  const target = LINKS[req.query['to'] ?? ''];
  if (target === undefined) return Response.json({ error: 'unknown link' }, { status: 404 });
  return redirect(target, 307);
}

[[redirects]] in gio.toml

For URLs that moved, a [[redirects]] rule is answered by the Rust server before routing, the page cache or Node. Patterns use the routing syntax: :param captures one segment and *rest any number of them, zero included:

gio.toml
[[redirects]]
from = "/blog/:slug"
to = "/posts/:slug"
status = 308                 # 301, 302 (default), 307 or 308

[[redirects]]
from = "/docs-old/*rest"
to = "/docs/*rest"           # /docs-old -> /docs, /docs-old/a/b -> /docs/a/b
text
$ curl -sI 'http://localhost:3000/blog/hello?ref=mail'
HTTP/1.1 308 Permanent Redirect
location: /posts/hello?ref=mail
  • The original query string is appended to the target verbatim.
  • Rules match the canonical path, so /blog//hello/ or an escaped spelling is caught by the same rule.
  • Any other status is a startup error, as is an unknown key: a typo never silently turns into a 302.
  • [[headers]] rules for the path apply to the redirect response too.

Redirects in middleware.ts

The same rules in TypeScript, typed by defineMiddleware. They travel to the Rust server when the worker starts and run there, exactly like gio.toml rules:

middleware.ts
import { defineMiddleware } from '@gio.js/core';

export default defineMiddleware({
  redirects: [
    { from: '/pricing-2025', to: '/pricing', status: 301 },
  ],
});

middleware.ts is declarative: it cannot look at cookies or call a database. For a decision per visitor, use a guard or getServerSideProps.

Guards

A [[guards]] rule redirects every request to its paths that lacks a credential - a valid session, or a cookie - with a 302 to redirect_to, before any of your code runs:

gio.toml
[[guards]]
path = "/admin/*rest"
require_session = true       # a valid createSessionStorage() cookie
redirect_to = "/login"
text
$ curl -sI 'http://localhost:3000/admin/users?tab=2'
HTTP/1.1 302 Found
location: /login?tab=2

The request's query string is appended, but the path the visitor asked for is not passed along. To send visitors back after they sign in, redirect from getServerSideProps with a next parameter instead, as shown above. See Authentication.

Order of evaluation

For each request the Rust server checks, in order:

  1. guards, then redirects, then rewrites - gio.toml rules before middleware.ts rules within each step. Every matching guard must admit the request; among redirects and among rewrites the first match wins;
  2. the page cache and static files;
  3. the Node worker: a page action, then getServerSideProps, or a route handler.

So a guard on a path beats a redirect rule for it, and a rule beats anything a page would answer. See Middleware.

On the client

In an event handler, move with useRouter() (push, replace) or navigate(). Both fetch the next page through the client router; when the server answers that request with a redirect, the router follows it and records the final URL.

tsx
import React from 'react';
import { useRouter } from '@gio.js/react';

function SignOut(): React.JSX.Element {
  const router = useRouter();
  return <button onClick={() => router.replace('/goodbye')}>Sign out</button>;
}

Choosing a status code

StatusMeaningMethod on the next requestUse for
301Moved permanentlyBrowsers switch POST to GETOld URLs of GET pages
308Moved permanentlyKeptOld URLs that also receive POSTs (APIs)
302Found, temporarily elsewhereBrowsers switch POST to GETTemporary moves, sign-in gates
307Temporarily elsewhereKeptTemporary moves of endpoints
303See otherAlways GETAfter a form post

redirect() accepts 301, 302, 303, 307 and 308 and throws a TypeError for anything else; [[redirects]] and middleware.ts accept all of those but 303. Browsers and search engines remember permanent redirects for a long time: use 302 or 307 until you are sure.

Good to know

  • Open redirects. Never redirect to a URL taken from the request unchecked: ?next=https://evil.example would turn your login page into a phishing hop. Accept only paths on your site:
    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;
    }
  • redirect() throws a TypeError for an empty URL or one with control characters, which would otherwise inject headers.
  • With i18n, ctx.path is the path without the locale prefix: add the prefix back (ctx.locale) when the target should keep the visitor's language.
  • A redirect can set cookies: pass headers to redirect(), or return { redirect, headers } from getServerSideProps.

Version history

VersionChanges
v0.1.0-beta.8redirect() for page actions, getServerSideProps and route.ts handlers, with headers. Redirects from actions, getServerSideProps and handlers carry Cache-Control: private, no-cache unless their headers set one. *rest matches zero segments. Rules match the canonical path, and header rules apply to redirect responses. A per-visitor redirect on a PPR shell hit reaches the visitor.