Error Pages

Custom _404.tsx and _500.tsx pages — file-based, layout-aware, and fully typed with error props.

Overview

Place _404.tsx and _500.tsx directly in app/pages/. They are standard server components and support everything regular pages do: layouts, useHtml(), client components, and HMR in dev. The underscore prefix tells NukeJS these are reserved error pages — they are excluded from routing, so /_404 and /_500 are never reachable as URLs.

_404.tsx — Page Not Found

Rendered whenever no route matches the requested URL. NukeJS automatically responds with a 404 HTTP status code.

app/pages/_404.tsxtypescript
import { useHtml } from 'nukejs'
import { Link } from 'nukejs'

export default function NotFound() {
    useHtml({ title: 'Page Not Found' })

    return (
        <main>
            <h1>404 — Page Not Found</h1>
            <p>The page you're looking for doesn't exist.</p>
            <Link href="/">Go home</Link>
        </main>
    )
}
ℹ️
Layouts wrap error pages too The root layout.tsx is applied to both _404.tsx and _500.tsx, so your nav and footer appear automatically — no extra wiring needed.

Preventing layout loops

Because error pages inherit the root layout, a bug inside layout.tsx — or any component it renders — will cause an infinite loop: the layout throws, NukeJS tries to render the error page, the layout throws again, and so on.

Guard against this by wrapping every non-essential layout component in a try / catch. If the component throws, return only children so the page content (including the error page) still renders:

app/pages/layout.tsxtypescript
import Component from "../component"

export default function Layout({ children }: { children: React.ReactNode }) {
    try {
        return (
            <>
                <div>{children}</div>
                <div><Component /></div>
            </>
        )
    } catch {
        // Component (or the layout itself) threw — fall back to bare children
        // so error pages can render without triggering another error cycle.
        return <>{children}</>
    }
}
⚠️
Any layout component can trigger the loop It doesn't have to be the layout function itself — a single crashing child component (e.g. a Nav or Footer that fetches data) is enough. Wrap anything that can fail.

_500.tsx — Internal Server Error

Rendered when a page handler throws an unhandled error. NukeJS automatically forwards error details as props so you can surface them — especially useful during development.

app/pages/_500.tsxtypescript
import { useHtml } from 'nukejs'
import { Link } from 'nukejs'

interface ErrorProps {
    errorMessage?: string  // human-readable error description
    errorStatus?:  string  // HTTP status code if set on the thrown error
    errorStack?:   string  // stack trace — only populated in development
}

export default function ServerError({ errorMessage, errorStack }: ErrorProps) {
    useHtml({ title: 'Something went wrong' })

    return (
        <main>
            <h1>500 — Server Error</h1>
            <p>Something went wrong on our end. Please try again.</p>
            {errorMessage && <p><strong>{errorMessage}</strong></p>}
            {errorStack   && <pre>{errorStack}</pre>}
            <Link href="/">Go home</Link>
        </main>
    )
}
ℹ️
errorStack is dev-only The stack trace is only populated when NODE_ENV !== 'production'. In production, errorStack is always undefined, so the <pre> block won't render — no accidental leaks.

Server errors

Any unhandled throw inside a server page component — including async data fetching — routes to _500.tsx. The error message and stack trace are forwarded as props automatically:

app/pages/dashboard.tsxtypescript
export default async function Dashboard() {
    const data = await fetchData() // throws → _500.tsx is rendered
    return <main>{data.name}</main>
}

Attach a status property to a thrown error to control the HTTP status code sent with the response. The value is forwarded to _500.tsx as the errorStatus prop:

app/pages/blog/[slug].tsxtypescript
export default async function Post({ slug }: { slug: string }) {
    const post = await db.getPost(slug)

    if (!post) {
        const err = new Error('Post not found')
        ;(err as any).status = 404
        throw err  // _500.tsx receives errorMessage="Post not found", errorStatus="404"
    }

    return <article>{post.title}</article>
}

Client errors

Unhandled errors in client components and async code are automatically caught and routed to _500.tsx via an in-place SPA navigation — no full page reload. Three mechanisms cover all cases:

MechanismWhat it catches
React Error BoundaryRender and lifecycle errors in every "use client" component
window.onerrorSynchronous throws in event handlers and other non-React code
window.onunhandledrejectionUnhandled Promise rejections from async functions
app/components/FaultyButton.tsxtypescript
"use client"

export default function FaultyButton() {
    const handleClick = () => {
        throw new Error('Something broke!')  // caught by window.onerror → _500.tsx
    }

    return <button onClick={handleClick}>Click me</button>
}
app/components/FaultyFetch.tsxtypescript
"use client"
import { useEffect } from 'react'

export default function FaultyFetch() {
    useEffect(() => {
        // Unhandled rejection → caught by window.onunhandledrejection → _500.tsx
        fetch('/api/broken').then(res => {
            if (!res.ok) throw new Error(`API error ${res.status}`)
        })
    }, [])

    return <div>Loading...</div>
}

The _500.tsx page receives errorMessage and errorStack props from client errors just like server errors, so a single error page handles both origins consistently.

Errors in API routes

API routes in server/ don't use the error pages — they respond directly. Use res.json() with the appropriate status code:

server/posts/[id].tstypescript
import type { ApiRequest, ApiResponse } from 'nukejs'

export async function GET(req: ApiRequest, res: ApiResponse) {
    const { id } = req.params as { id: string }
    const post = await db.getPost(id)

    if (!post) {
        res.json({ error: 'Post not found' }, 404)
        return
    }

    res.json(post)
}

export async function POST(req: ApiRequest, res: ApiResponse) {
    try {
        const created = await db.createPost(req.body)
        res.json(created, 201)
    } catch (err) {
        console.error('[POST /posts]', err)
        res.json({ error: 'Internal server error' }, 500)
    }
}

Behaviour reference

ScenarioWithout error pageWith error page
Server page throwsPlain-text Internal Server Error (500)_500.tsx rendered with error props
Client component render errorReact crashes the component subtree_500.tsx rendered in-place, no reload
Unhandled event handler throwBrowser console error only_500.tsx rendered in-place, no reload
Unhandled promise rejectionBrowser console error only_500.tsx rendered in-place, no reload
Unknown URLPlain-text Page not found (404)_404.tsx rendered with 404 status
<Link> to unknown URLFull page reloadIn-place SPA navigation, no reload
HMR save of _404.tsx / _500.tsxCurrent page re-fetches immediately
Production notes

Both error pages are fully bundled into the production output for Node.js and Vercel — no runtime file-system access is required. The correct HTTP status code (404 or 500) is always set on the response, and errorStack is stripped in production so stack traces never reach end users.