GioJSdocs
On this page

Error Handling

404 and error UI per folder, with special files.

Two special files control the non-happy paths. Both may sit in any folder of app/:

  • not-found.tsx - rendered with status 404 when a page calls notFound(); the one in app/ also answers unmatched URLs
  • error.tsx - rendered with status 500 when a render throws, and an error boundary in the browser

For a page, the nearest file at or above its folder applies - route groups and dynamic folders included - and it renders inside the layouts of its own folder and the folders above it. Without any, GioJS serves clean built-in pages instead.

Not found

Call notFound() from @gio.js/core in getServerSideProps or while rendering, or return { notFound: true } from getServerSideProps (see Fetching Data). The response is a 404 with the nearest not-found.tsx:

text
app/
  not-found.tsx          # unmatched URLs, and pages with no closer file
  shop/
    layout.tsx
    not-found.tsx        # /shop/* pages that call notFound() - inside shop/layout.tsx
    [id]/page.tsx
app/not-found.tsx
export default function NotFound() {
  return <div><h1>404</h1><p>Page not found.</p></div>;
}

URLs that match no route always get the app/not-found.tsx - they belong to no folder. A 404 is never cached, even on a page that exports revalidate: it can depend on anything getServerSideProps read, and a cached 404 would outlive the content appearing. When a cached page starts answering 404 - its data was deleted - the background revalidation that sees the 404 evicts the cached copy, so the deleted page is not served for the rest of the stale-while-revalidate window. A route.ts handler that calls notFound() answers { "error": "Not Found" } with status 404.

Errors

When a page, its getServerSideProps, or a layout below the error.tsx's folder throws, the nearest error.tsx renders with status 500. An error.tsx does not catch errors of the layout in its own folder - it renders inside that layout - so those go to the error.tsx of a parent folder (the Next.js rule). An error in app/layout.tsx itself gets the built-in error page.

The error page receives the failure via props as { error: { message, digest }, reset } (the Next.js shape). In development message is the real error message; in production it is always the generic Internal Server Error, so nothing from the exception can leak into the page. digest is a short random error reference in both modes - show it so users can quote it:

app/error.tsx
import type { ErrorPageProps } from '@gio.js/core';

export default function Error({ error, reset }: ErrorPageProps) {
  return (
    <div>
      <h1>Something went wrong</h1>
      {process.env.NODE_ENV === 'development' && <pre>{error.message}</pre>}
      {error.digest && <p>Error reference: <code>{error.digest}</code></p>}
      {reset && <button onClick={reset}>Try again</button>}
    </div>
  );
}

In the browser

Every error.tsx is also a React error boundary in the hydrated page, so it ships in the client bundle of the pages below it (like a layout - importing server-only code from one rejects those bundles). When rendering throws after hydration - say an event handler sets state that a component cannot render - the nearest boundary replaces just its segment, and the layouts above it stay interactive. reset() renders the segment again. Errors thrown by event handlers themselves, outside rendering, are not render errors and never reach a boundary.

In the browser, too, message is real only in development. In production it is the generic Application Error, and there is no digest: the boundary only ever sees an error thrown in the browser. That includes a part the server could not finish while streaming - React renders it again in the browser, and the boundary catches only what fails there. The server's failure is in the server log under its own digest; in the browser React reports it as a recoverable error on the console, never to the boundary. A notFound() that runs in the browser reaches the nearest boundary as Not Found; not-found.tsx files are server-only.

Upgrading: error.tsx used to render only on the server. It is now client code, bundled into every page below its folder (app/error.tsx into every page) - so it must be browser-safe like a page component. An existing error.tsx that imports server-only code (a *.server.ts module, server-only, a Node builtin, a server-side logger) costs those pages their client bundles: they are still server-rendered but no longer hydrate, and the build log names the error.tsx. Report errors from it through a route.ts instead.

reset exists only on a boundary caught in the browser: the 500 page the server renders is static HTML - link the user home or ask them to reload there.

Streaming and loading.tsx

A failure is answered with a 404 or 500 page only while nothing has been sent yet. Under a loading.tsx that means: if the page throws (or calls notFound()) before it suspends, the response is still the error or not-found page, exactly as without the loading.tsx. Once the page has suspended, the status and the loading UI are on their way; an error after that is handled by React like any Suspense boundary - the browser renders the segment itself, and if it fails there too, the nearest error.tsx boundary shows it. So a page that suspends and then throws answers 200 under a loading.tsx, where without one it would have failed the whole render with a 500. A cacheable page, rendered completely before it is sent, gets the same answer as when it streams. Errors inside your own <Suspense> boundaries always get that client-side recovery; only a notFound() there still answers 404 while nothing has been sent. A render that recovered this way is never cached, even on a page with revalidate - with partial prerendering, its shell is not stored either. See Streaming.

Production error responses

A production response never carries an error message or stack. Without an app/error.tsx, a failed render is answered with a plain page naming only the status and the error reference. The real message and stack are logged server-side under the same digest, so a user report finds the exact failure - and its requestId finds every other log line of that request (see Observability):

bash
{"level":"error","msg":"ssr render failed","requestId":"0b8e3c52-7a1d-4f0e-9c3b-5d2a6e8f1a47","path":"/posts/7","digest":"3f9a1c0b7e2d","error":"connect ECONNREFUSED 10.0.0.5:5432","stack":"Error: connect ECONNREFUSED ..."}

Production means anything other than NODE_ENV=development when the server starts: unset and test are production too. The Rust server makes this decision once and starts the Node worker with the matching NODE_ENV, so the two halves always agree.

Development error overlay

In development, SSR render errors - plus browser window errors and unhandled promise rejections - open a full-screen overlay instead of a bare 500. The overlay parses the stack, and for the topmost frame in your project it shows a codeframe: the failing line with four lines of context on each side, fetched from the dev-only /_gio/devtools/codeframe endpoint (reads are confined to source files inside the project root, checked after resolving symlinks).

Every file:line in the stack is a link - clicking it opens the file at that line in your editor via /_gio/devtools/open-in-editor. The editor command comes from the first non-empty of GIO_EDITOR, VISUAL, EDITOR, defaulting to code; VS Code-family editors (code, cursor, windsurf, codium) get the -g file:line goto form, everything else a plain file:line argument.

bash
# examples
GIO_EDITOR=cursor npm run dev
GIO_EDITOR="subl -w" npm run dev
The overlay, the codeframe endpoint, and open-in-editor exist only in dev mode - none of it is compiled into production responses.

Both endpoints only answer to localhost hosts, on connections from this machine. The codeframe endpoint refuses cross-site requests; open-in-editor takes same-origin POST only. The SSR error page follows the same rule: for any other host it leaves out the error message and stack (the terminal still logs them). If you open the dev server through a LAN IP or hostname, add it to [dev] allowed_hosts in gio.toml (see Configuration) or error details, codeframes and editor links will not work from there.

Static export

gio export writes your app/not-found.tsx (or the built-in default) to out/404.html, which static hosts like Cloudflare Pages, GitHub Pages, and Netlify serve with a real 404 status for unknown URLs - without it, many hosts fall back to the home page with a 200. Pages that call notFound() at export time are skipped - nothing is written for them - and a page that fails to render is listed with its error reference instead of being exported as an error page. That includes an error caught by a <Suspense> or loading.tsx boundary: an exported page never hydrates, so the browser could never recover the boundary and its fallback would stay on screen.

API routes

Errors thrown in route.ts handlers are logged server-side and answered with a JSON 500, { "error": "Internal Server Error", "digest": "..." } - internal details never reach the client; the digest matches the log line. A handler that calls notFound() gets a JSON 404. Requests for methods a handler file doesn't export get 405 with an Allow header.