Upgrading
Move an app from GioJS 0.1.0-beta.7 to 0.1.0-beta.8 step by step: packages, scripts, gio.toml, routes, the new security defaults, caching, errors and deploys.
Beta.8 turns on production defaults - CSRF protection, security headers, bounded connections, strict configuration - and changes a few behaviors apps relied on. Most apps need only steps 1 to 4; the rest apply when you use the feature they name. Each step says what changed, how to tell whether it affects you, and what to change. The complete list is the release notes; what each new default protects, and how to turn it off, is in Turning Protections On and Off.
1. Update the packages
Install the new versions together: the server binary and @gio.js/core check each other's protocol version when the worker starts, and gio doctor reports @gio.js/* packages that are not in lockstep. Add @gio.js/core if it is not a direct dependency yet (types and server helpers are imported from it), and move cross-env to dependencies, since npm start now uses it in production:
npm install @gio.js/[email protected] @gio.js/[email protected] @gio.js/[email protected] cross-envpnpm add @gio.js/[email protected] @gio.js/[email protected] @gio.js/[email protected] cross-envyarn add @gio.js/[email protected] @gio.js/[email protected] @gio.js/[email protected] cross-envbun add @gio.js/[email protected] @gio.js/[email protected] @gio.js/[email protected] cross-envBuilding the server from source (instead of the published binary) needs Rust 1.89 or newer.
2. Fix the start script
What changed: the server decides the runtime mode, and the Node worker follows it - development only when the server starts with NODE_ENV=development, production otherwise. Beta.7 ran a development worker (dev bundles, source maps, error stacks) behind a production server when NODE_ENV was unset.
"scripts": {
"dev": "cross-env NODE_ENV=development giojs-server",
- "start": "giojs-server"
+ "start": "cross-env NODE_ENV=production giojs-server"
},The new gio dev and gio start set the mode themselves. Bare gio no longer starts a server: it prints the help and exits with code 2. Replace it in scripts and Dockerfiles:
- CMD ["npx", "gio"]
+ CMD ["npx", "gio", "start"]The giojs-server binary now takes --check-config alone. Any other argument - --port 4000, --version - prints unexpected argument and exits with code 2 without starting; beta.7 ignored it and started. Set the port with GIO_PORT (or [server] port), and use gio --version for versions:
- "start": "giojs-server --port 4000"
+ "start": "cross-env NODE_ENV=production GIO_PORT=4000 giojs-server"3. Validate your configuration
What changed: an unknown section or key anywhere in gio.toml now stops startup with the file, the line and the closest valid key. gio.config.ts is validated too (unknown keys, plugins without a name), and a [[guards]] entry with a misspelled key, no requirement or an invalid path fails startup instead of being skipped. Run the check before you deploy. It never binds a port, and lists every validation problem at once - unknown keys and sections together with invalid values, broken rules and [i18n] mistakes. Only a TOML syntax error is reported on its own, and a required key whose value is invalid (path = 3) holds back the problems after it, so run the check again after fixing those:
npx giojs-server --check-config # JSON report; exit code 1 when startup would fail
npx gio doctor # the same check, plus Node, versions, tsconfig and the portgio.toml:5: unknown key [image] - did you mean [images]?Startup also refuses rules and settings that beta.7 skipped or ignored:
- Rules that cannot be enforced. A
[[redirects]],[[rewrites]]or[[headers]]rule that cannot be compiled - a relative pattern, a catch-all that is not last, an unknown capture, a bad status or header - stops startup, like a broken guard. A redirect or rewriteto, and a guard'sredirect_to, must be a path on this site://evil.comand/\evil.com, which a browser reads as another site, are refused. Send visitors to another site from a route handler.textgio.toml:1: invalid [[redirects]] entry for "/a": target "//evil.com" is another site (a browser reads a leading // or /\ as one): redirect to another site from a route handler [i18n]is checked. An unknowndetect_fromvalue, an empty or duplicate locale, and adefault_localethat is not one of a non-emptylocalesstop startup.default_localedefaults to"en", so an app that listslocaleswithoutenmust now name its default:gio.toml[i18n] locales = ["de", "fr"] + default_locale = "de"middleware.tsfails closed. Amiddleware.tsthat throws while it loads, has no default export, or holds a rule that cannot be enforced (an invalid pattern, a malformed field, an unknown key, a guard without a requirement, a target that leaves the site) stops the worker at boot with every problem listed. In production the server exits with code1; in development it waits for you to save a fix. Beta.7 dropped the rules - every rule, guards included, for a file that threw - with a warning and kept serving.--check-configdoes not loadmiddleware.ts: start the app once (npm start) to check it.textgiojs-server: the Node worker exited before it was ready (exit status: 1): /srv/shop/middleware.ts failed to load: GIO_SESSION_SECRET is not set
Keys that never did anything are now rejected with what to use instead:
[cache]
- memory_mb = 256
+ memory_max_entries = 1000 # pages kept in memory
- [cache.redis] # no Redis backend exists yet: remove it
- url = "redis://cache:6379"
- [css]
- engine = "lightningcss" # remove: there is one engine
- [prefetch]
- strategy = "hover" # remove: choose per link with <GioLink prefetch>
- [my-tool] # settings for other tools: name the table x-...
+ [x-my-tool]
option = trueAlso check these, which changed meaning:
0now lifts a limit everywhere. In beta.7,[websocket] max_connections = 0closed every socket,[websocket] ping_interval_secs = 0crashed every connection, and[images] max_remote_bytes = 0rejected every remote image. Each now means unlimited (no pings). If you used0to turn something off, use its switch:Thegio.toml[websocket] - max_connections = 0 + enabled = false[prefetch]keys are new in beta.8 - beta.7 ignored the section and always used the built-in budget - and follow the same rule:max_concurrent = 0ormax_per_second = 0lifts that budget, and[prefetch] enabled = falseturns prefetching off.[server] max_body_bytes = 0used to answer413to every body; it now means no limit of its own (the worker's message cap, about 48 MiB of binary body, still applies) and logs a warning.- A malformed
[metrics] ip_allowlistentry stops startup, like a malformedtrusted_proxiesentry. - The page cache directory (
[cache] disk_pathorGIO_CACHE_DIR) may no longer be, contain or sit insideapp/orpublic/.
New apps' gio.toml starts with a schema line that gives editors completion and hover docs; add it to yours:
#:schema ./node_modules/@gio.js/server/gio.schema.json4. Review your .env files
What changed: the server now loads .env.{mode}.local, .env.local, .env.{mode} and .env from the project root at startup, with variables already in the environment winning. A committed .env that was ignored before now applies - check what it sets. NODE_ENV in a file is ignored, and a file that cannot be parsed stops startup with its name and line. To keep the old behavior, turn loading off:
[env]
files = false # or GIO_ENV_FILES=0 in the environmentOnly GIO_PUBLIC_* variables reach browser code. See Environment Variables.
5. Check your routes
What changed: app/ follows the App Router folder rules. Run gio routes before and after upgrading and compare the URLs:
(group)folders no longer appear in URLs, and_privatefolders are never routed: move routes out of_-prefixed folders.[...slug]matches one or more segments and[[...slug]]zero or more; the param is one/-joined string ('a/b', or''for an optional catch-all that matched nothing):app/docs/[...slug]/page.tsximport type { GsspContext } from '@gio.js/core'; export async function getServerSideProps(ctx: GsspContext<'/docs/*slug'>) { const parts = ctx.params.slug.split('/'); // /docs/guides/install -> ['guides', 'install'] return { props: { parts } }; }- Layouts come from the folder tree, so layouts inside dynamic folders and route groups now apply. Two files that answer the same URL fail startup, naming both.
public/is served at the site root (/favicon.ico,/robots.txt) ahead of pages: a public file wins over a page with the same path. It defaults to the directory next toapp/(GIO_PUBLIC_DIRoverrides it)./_gio/belongs to the framework: an app route there now answers404.- Request paths are canonical: repeated and trailing slashes collapse before rules match, and paths with
.or..segments or a bad%escape get400. *restin guards, redirects, rewrites and header rules now also matches zero segments:/admin/*restcovers/admin, and a[[rate_limits]]path/api/*covers/api.- A
route.tsthat throws while it is imported now answers500instead of404.
6. Allow legitimate cross-site requests
What changed: CSRF protection is on. Cross-site POST, PUT, PATCH and DELETE requests from browsers get 403 before your code runs; cross-origin WebSocket upgrades get 403 too. Requests without browser headers - curl, server-to-server webhooks - pass. You are affected if another site posts the browser to you: OAuth/OIDC response_mode=form_post callbacks (Sign in with Apple, Entra ID), SAML ACS endpoints, 3-D Secure returns, or a second front-end on another origin:
[security.csrf]
exempt = ["/auth/callback/apple", "/saml/acs", "/api/webhooks/*rest"]
trusted_origins = ["https://admin.example.com"] # your other origins (also for WebSockets)Behind nginx, keep proxy_set_header Host $host (or list the proxy in [server] trusted_proxies so X-Forwarded-Host counts): the check compares Origin with the host. See Security: CSRF protection.
7. Check pages embedded in frames
What changed: every response carries X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN and Referrer-Policy: strict-origin-when-cross-origin (plus HSTS with [server.tls]), and X-Powered-By is removed. If other sites embed your pages in an <iframe>, lift the frame header for those paths:
[[headers]]
path = "/embed/*rest"
[headers.headers]
x-frame-options = "" # "" removes the default for these paths8. Send a JSON content type
What changed: in route handlers, req.json() only parses bodies sent as application/json or application/*+json; anything else throws UnsupportedMediaTypeError, a 415 unless caught. Make your fetch() calls say what they send:
await fetch('/api/posts', {
method: 'POST',
+ headers: { 'content-type': 'application/json' },
body: JSON.stringify(post),
});A body that is empty, not UTF-8 or not valid JSON now throws MalformedBodyError - a 400 unless caught - instead of the parser's SyntaxError or a plain Error (a 500). A catch that tests err instanceof SyntaxError silently stops matching; test isMalformedBodyError(err) instead:
- import type { GioRequest } from '@gio.js/core';
+ import { isMalformedBodyError, type GioRequest } from '@gio.js/core';
export function POST(req: GioRequest) {
let post: unknown;
try {
post = req.json();
} catch (err) {
- if (err instanceof SyntaxError) return Response.json({ error: 'Invalid JSON' }, { status: 400 });
+ if (isMalformedBodyError(err)) return Response.json({ error: 'Invalid JSON' }, { status: 400 });
throw err;
}
return { created: post };
}req.body still holds the raw body for other formats, and req.formData() parses forms. See Request errors.
9. Check cached pages that read cookies
What changed: a page that exports revalidate but whose getServerSideProps reads ctx.cookies, the cookie or authorization header, ctx.ip, ctx.host or ctx.scheme now renders per request and is never stored - beta.7 cached it and served the first visitor's page to everyone. Neither is a response that sets a cookie. A warning in the server log names each such route. Either drop revalidate, or cache the shared part and personalize inside Suspense holes:
export const revalidate = 60;
+ export const shell = 'cache'; // cache everything above the first pending <Suspense>Cached pages also send Cache-Control: public, max-age=0, s-maxage=<revalidate>, stale-while-revalidate=... now, so a CDN in front caches them - and an on-demand purge does not reach the CDN. Set your own Cache-Control with a [[headers]] rule where that is not wanted. See Caching.
10. Update error.tsx
What changed: in production a failed render shows only a digest - a short reference the real error is logged under - and error.tsx receives { error: { message, digest }, reset }, where message is Internal Server Error. error.tsx is also a client error boundary now, bundled into every page below its folder, so it must not import server-only code:
- interface ErrorPageProps { error?: { message: string } }
+ import type { ErrorPageProps } from '@gio.js/core';
- export default function Error({ error }: ErrorPageProps) {
+ export default function Error({ error, reset }: ErrorPageProps) {
return (
<div>
<h1>Something went wrong</h1>
+ {error.digest !== undefined && <p>Error reference: <code>{error.digest}</code></p>}
+ {reset !== undefined && <button onClick={reset}>Try again</button>}
</div>
);
}Search your logs for the digest to find the message and stack. See Error Handling.
11. WebSockets
If you use wsHandler or useWebSocket:
useWebSocketreconnects by default, with backoff. Passreconnect: falsefor the old behavior.- A path with no
wsHandlercloses with4404. - An async
wsHandlernow decides the connection: the socket gets broadcasts only once it resolves (to anything butfalse), and a rejected promise closes it with1011. A handler that awaits for the socket's whole lifetime must return once its listeners are set up:app/chat/route.tsimport { broadcast, type GioSocket } from '@gio.js/core'; export async function wsHandler(socket: GioSocket) { socket.join('lobby'); socket.on('message', (data) => broadcast('lobby', String(data))); - await new Promise((resolve) => socket.on('close', resolve)); // pending while the socket is open }
See WebSockets.
12. Plugins and header rules that touch cookies
Cookies a page or route handler sets now reach an onResponse plugin in res.setCookies, not res.headers['set-cookie']; set setCookies: [] to strip them. A set-cookie header rule adds its cookie next to the response's own instead of replacing them. Header rules also apply to redirect and guard responses, and match the requested path rather than a rewritten one.
13. Typed routes
.gio/routes.d.ts now fills a global registry that href(), useParams() and the @gio.js/core types all read. Routes you added by hand to @gio.js/react's GioRegisteredRoutes still type href(); move them so the core types see them too:
- declare module '@gio.js/react' {
- interface GioRegisteredRoutes {
- '/legacy/:id': { id: string };
- }
- }
+ declare global {
+ namespace GioJS {
+ interface RegisteredRoutes {
+ '/legacy/:id': { id: string };
+ }
+ }
+ }
+ export {};In CI, run gio typegen before tsc so the file exists without starting a server.
14. Development from another machine
What changed: the dev endpoints (error-overlay codeframes, open-in-editor, the dashboard) and dev error details answer only local hosts on a connection from the same machine. If you open the dev server from a VM, a container port mapping, a phone on your network or a tunnel, list that host:
[dev]
allowed_hosts = ["myvm.local", "192.168.1.20", "*.tunnel.example"]An entry of "*", which beta.7 dropped as invalid, now opens the dev endpoints and error details to every host and every machine, with a startup warning. Open-in-editor still takes same-origin requests only.
15. Deploys
- Keep-alive. Idle HTTP/1.1 keep-alive connections now close after 10 seconds (
[server] header_read_timeout_secs). A proxy that pools upstream connections longer - nginxkeepalive, ingress-nginx and AWS ALB default to 60 seconds - can answer with occasional502s. Keep the proxy's upstream idle timeout below 10 seconds, or raiseheader_read_timeout_secsandidle_timeout_secsabove it. - Metrics. A
[metrics]section with neithertokennorip_allowlistanswers only this machine now. Give your scraper a token or an allowlist:Behind a proxy on the same machine, list it ingio.toml[metrics] ip_allowlist = ["10.0.0.0/8"] # your Prometheus network # ip_allowlist = ["0.0.0.0/0", "::/0"] # everyone, as before (startup warns)[server] trusted_proxies, or every client looks local. - Static export.
gio exportnow ships client bundles and each page'sgetServerSidePropsprops as JSON: never return secrets from it.public/is copied to the root ofout/. - create-giojs rejects unknown flags and needs
--forcefor a non-empty directory; it runsgit initunless you pass--no-git.
16. Verify
npx gio doctor --prod # configuration, versions, secrets, port
npx gio routes # the URLs your app answers
npm start # production mode; read the startup warnings
curl -sI http://localhost:3000/ | grep -i 'x-frame-options\|cache-control\|x-gio-cache'Every startup warning names a gio.toml key that loosens a protection; each one should be deliberate. Then follow the Production Checklist.