Middleware
Declarative redirects, rewrites, response headers, and auth guards - executed in Rust before routing.
GioJS middleware is a set of declarative rules, not request-time JavaScript. You describe redirects, rewrites, response headers, and session or cookie guards; they are compiled once at load time and evaluated in the Rust HTTP layer on every request - before routing, before the cache, and before any Node code runs. Because the rules execute inside the server itself rather than in a separate step, there is no request header that skips them (contrast with Next.js middleware, where thex-middleware-subrequest header could bypass authorization checks entirely - CVE-2025-29927). A request either satisfies the rules or never reaches your pages.
Rules come from two places, and you can use either or both:
- gio.toml - static rules in your server config
- middleware.ts - a file at the project root (sibling of
app/)
For every way to redirect - rules, guards, page actions and getServerSideProps - and which status code to use, see Redirecting.
gio.toml rules
[[redirects]]
from = "/old-home"
to = "/"
status = 301 # 301, 302, 307, or 308 - defaults to 302
[[redirects]]
from = "/blog/:slug"
to = "/posts/:slug"
[[rewrites]]
from = "/docs/*rest" # served under the requested URL
to = "/guide/*rest"
[[headers]]
path = "/docs/*rest"
[headers.headers]
x-frame-options = "DENY"
[[guards]]
path = "/admin/*rest"
require_session = true # a valid, unexpired session (see Authentication)
redirect_to = "/login"middleware.ts
The same four rule kinds, typed. Export the result of defineMiddleware as the default export (guard fields are camelCase here):
// middleware.ts (project root, next to app/)
import { defineMiddleware } from '@gio.js/core';
export default defineMiddleware({
redirects: [
{ from: '/blog/:slug', to: '/posts/:slug', status: 301 },
],
rewrites: [
{ from: '/docs/*rest', to: '/guide/*rest' },
],
headers: [
{ path: '/admin/*rest', headers: { 'x-frame-options': 'DENY' } },
],
guards: [
{ path: '/admin/*rest', requireSession: true, redirectTo: '/login' },
],
});These rules travel to the Rust server inside the worker's READY frame and refresh whenever the worker restarts. In development the watcher restarts the worker on source changes anywhere in the project, including middleware.ts, gio.toml, and gio.config.*, so middleware edits are picked up with the next restart. gio.toml rules are compiled once at server startup.
Pattern language
Patterns use the routing conventions and must start with /:
- Literal segments -
/aboutmatches exactly/about :param- captures one segment:/posts/:idmatches/posts/42but not/postsor/posts/a/b*rest- captures the entire remainder, slashes included, and may be empty:/docs/*restmatches/docs/a/b/cand/docsitself. It must be the last segment. A guard on/admin/*resttherefore also protects/admin, and one/*restheader rule covers the whole site, root included
Captures substitute into to targets by name, in any order. An empty catch-all contributes no segment, so /old/*rest → /new/*rest sends /old to /new:
[[redirects]]
from = "/u/:user/p/:post"
to = "/p/:post/by/:user" # /u/alice/p/42 -> /p/42/by/aliceRules match the canonical request path - the same path the router resolves. Repeated slashes collapse, a trailing slash is ignored, and percent-escapes of unreserved characters (letters, digits, - . _ ~) are decoded, so /admin/, //admin and /%61dmin all meet a guard written for /admin. Your app sees those escapes decoded too, so a rule and the router can never disagree about which page a request reaches. A path containing a . or .. segment (raw or escaped), or a % that does not start a valid escape (/%zz, /a%), is rejected with 400 before any rule or route runs. [[rate_limits]] use the same canonical form.
Every rule is validated when it is loaded, never at request time, and a rule that cannot be enforced as written is an error, not a warning: a skipped guard would leave its path open, and a skipped redirect or header rule would quietly serve what it was meant to change. That covers a relative pattern (members/*rest), a catch-all that is not the last segment (/a/*rest/c), a to target that is not a path on this site or references an unknown capture, a disallowed redirect status, an invalid header name or value, a guard that names no requirement or whose redirectTo is not a path on this site, and an unknown key (a misspelled require_session). A path on this site starts with one /: //evil.example and /\evil.example are refused, because a browser reads them as another site.
- gio.toml - the server refuses to start, naming the file, the line and the reason.
- middleware.ts - the worker refuses to boot, listing every problem with the rule's position (
guards[0] ("members/*rest"): path must start with "/"). So does amiddleware.tsthat throws while it loads, or has no default export: its rules - guards included - are never dropped while the app serves. In production the server exits with the error (see worker boot errors); in development it waits for the fix, and a file broken by a later edit makes the worker answer503until it is fixed.
Evaluation order
Per request, the short-circuiting phases run in a fixed order:
- Guards
- Redirects
- Rewrites
Guards are not first-match: every guard whose path matches is checked, and each must admit the request. The first one that refuses (in order, gio.toml guards before middleware.ts guards) decides the redirect, so a /admin/*rest session guard and a /admin/billing cookie guard both apply to /admin/billing. Among redirects, and among rewrites, the first matching rule wins, gio.toml rules first. The phase order holds across both sources - a middleware.ts guard beats a gio.toml redirect on the same path.
The original query string is preserved verbatim: redirects and guard redirects append it to the Location header (/admin?next=1 goes to /login?next=1), and rewrites keep it on the rewritten URI. Redirect and rewrite targets are paths on this site, starting with one / - for an external URL, redirect from a route handler or getServerSideProps. A rewrite changes the path that routing and the cache key see, while the browser URL stays what the client requested.
Guards
A guard redirects (302) any request to a matching path that lacks the credential it requires - the request never reaches Node. There are two kinds:
require_session = true(requireSession: true) - thegio_sessioncookie must hold a session fromcreateSessionStoragewhose signature verifies withGIO_SESSION_SECRET(any rotated secret) and that has not expired. Rust checks both before routing; anything else is treated like a missing cookie. Addrequire_cookieto read a session stored under another cookie name. Without a valid secret the guard denies every request and the server logs why. See Authentication.require_cookie = "name"alone - a presence check: any non-empty cookie of that name passes. It keeps anonymous traffic out cheaply but proves nothing, so validate the cookie itself ingetServerSidePropsor a route handler.
A page a guard lets through is for that visitor only: even when GioJS caches it, it goes out as Cache-Control: private, no-cache without an ETag, so a CDN (which never runs the guard) cannot serve it to anyone else. See Caching.
Header rules
Header rules stamp response headers and do not short-circuit: every header rule whose path matches contributes its headers. They match the path the client requested (before any rewrite), apply to redirect responses produced by redirect and guard rules as well - so security headers like strict-transport-security cover those too - and names/values are validated once at load time. A rule's value replaces the response's own value for that header, except set-cookie: a rule cookie is added next to the cookies the page or route handler set, never in place of them.
Header rules also win over the default security headers (x-frame-options, referrer-policy, CSP, ...), and an empty value removes such a default for the rule's paths - x-frame-options = "" lets other sites frame /embed/*rest. See Security.
public/ files at the site root
A file in public/ answers at its root URL as well as under /public/* - public/members/report.pdf is both /members/report.pdf and /public/members/report.pdf. Rules written for the /public/... URL follow the file to its root URL:
- Guards for the
/public/...URL run after the requested URL's own rules let the request through, so a guard on/public/members/*restalso protects/members/report.pdf. - Header rules for both URLs are stamped; when both set the same header, the rule for the requested URL wins.
[[rate_limits]]for both URLs must admit the request, and a rule matching both is charged once.- Redirects and rewrites match only the URL requested, so a
/public/*rest→/*restredirect that moves old links to the root does not loop. - The image optimizer is held to the guards of both URLs: a local
/_gio/imagesrcthat a guard covers gets403for visitors the guard turns away, andCache-Control: private, no-cachefor those it admits.
An escaped slash or backslash (%2F, %5C) never names a file: under /public/ it gets 400, and at the root the request goes to your pages, so /members%2Freport.pdf cannot reach the file past the rules for /members/*rest.
/_gio endpoints (health, metrics, image optimization, fonts, and devtools in development) are exempt from all middleware rules (the image optimizer still checks the guards of the public/ file it reads). Every other /_gio/... path answers 404 from Rust and never reaches your pages, so a top-level dynamic segment like app/[org]/ can never be rendered with org = "_gio" behind your rules' back.