ehsan.blog
~/blog/how-to-build-an-ssr-safe-react-error-boundary — zsh
cat how-to-build-an-ssr-safe-react-error-boundary.md

How to build an SSR-safe React error boundary

·4 min read

One thrown error during render can blank your entire React page. If a single component hits a bad state and throws, React unmounts the whole tree — the visitor sees a white screen with no explanation. An error boundary is the standard fix: a component that catches errors from its children and renders a fallback UI instead of crashing everything.

I use one to wrap my whole app. Here’s how it works, and — because my portfolio is statically prerendered — how to keep it safe on the server too.

What an error boundary actually is

An error boundary has to be a class component. This is one of the few things React still can’t do with hooks: there’s no useErrorBoundary. The magic comes from two special class methods:

  • static getDerivedStateFromError() — runs when a child throws, and returns the new state (so you can flip a hasError flag).
  • componentDidCatch(error, info) — runs after, and is where you log the error to the console or a service like Sentry.

Here’s the full component I use.

tsx
// src/components/shared/error-boundary.tsx
import { Component, type ErrorInfo, type ReactNode } from "react"

interface Props {
  children: ReactNode
}

interface State {
  hasError: boolean
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false }

  static getDerivedStateFromError(): State {
    return { hasError: true }
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    // Surfaced in the console during dev; wire to Sentry/analytics here later.
    console.error("Uncaught error:", error, info)
  }

  render() {
    if (this.state.hasError) {
      return (
        <div
          role="alert"
          className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-6 text-center font-mono"
        >
          <p className="text-sm text-muted-foreground">
            Something went wrong rendering this page.
          </p>
          <button type="button" onClick={() => window.location.reload()}>
            Reload
          </button>
          <a href="mailto:ehsanshahid787@gmail.com">or email me directly</a>
        </div>
      )
    }

    return this.props.children
  }
}

State starts as { hasError: false }, so on a normal render the component just returns this.props.children — it’s invisible. When a child throws, getDerivedStateFromError flips hasError to true, React re-renders, and now the fallback branch runs instead.

The recovery UI

A fallback that just says “Error” leaves the visitor stuck. Mine gives them a way out:

  • role="alert" tells screen readers to announce the message immediately.
  • A Reload button calls window.location.reload() to try a fresh render.
  • A mailto: link so they can tell me directly if it keeps happening.

That’s the whole point of a boundary — contain the damage and offer a next step, instead of a dead white page.

Why “SSR-safe” matters here

My portfolio is prerendered with vite-react-ssg: at build time the React tree renders once in Node.js and the HTML is baked into dist/index.html. Node has no window, no document, no localStorage. If any of those are touched during render, the build crashes.

Look closely at where I use window in the boundary: it’s inside onClick. That’s an event handler — it only runs in the browser after a real click, never during render. The render() method itself touches no browser globals. That’s the rule:

flowchart TD
  A[render runs on server AND client] -->|must be browser-free| B[no window / document / localStorage]
  C[onClick, effects] -->|browser only| D[window.location.reload OK here]

If I had written window.location.reload() directly in the render body, the build would throw window is not defined. Keeping browser calls inside handlers keeps the same component working in both places — the same discipline covered in keeping React code SSG-safe by avoiding browser globals.

Wrapping the whole app

The boundary only catches errors from components below it, so I wrap it as high as possible — around <App /> itself in the entry file:

tsx
// src/main.tsx
import { ViteReactSSG } from "vite-react-ssg/single-page"
import App from "./App.tsx"
import { ErrorBoundary } from "./components/shared/error-boundary.tsx"

export const createRoot = ViteReactSSG(
  <ErrorBoundary>
    <App />
  </ErrorBoundary>,
)

Now any render error anywhere in the app bubbles up to this one boundary and shows the recovery screen instead of crashing the page.

One caveat worth knowing: error boundaries only catch errors during rendering, lifecycle methods, and constructors of their children. They do not catch errors inside event handlers, async code, or the boundary’s own render. For those you still use regular try/catch.

What to remember

  • Error boundaries must be class componentsgetDerivedStateFromError sets the fallback state, componentDidCatch logs.
  • Keep render() free of window/document so the same component survives static prerendering.
  • Put browser-only calls like window.location.reload() in event handlers, never in render.
  • Give the fallback a real recovery path — a reload button and a contact link.
  • Wrap the boundary around <App /> at the top so it catches errors from the whole tree.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.