Skip to content

Not Found and Error Boundary ​

Problem ​

Not-found pages and data errors need different treatment: one is expected (wrong URL), one is unexpected (thrown exception). Both need centralized handling without special router hooks.

Solution ​

Use the notFound option in createRouter for unmatched URLs. Wrap await next() in a try/catch middleware for error boundaries.

ts
import { createRouter } from '@vielzeug/wayfinder';

const errorBoundary = async (ctx, next) => {
  try {
    await next();
  } catch (error) {
    // Report and redirect on any unhandled error from data().
    reportError(error, { path: ctx.pathname });
    await ctx.navigate({ path: '/error' }, { replace: true });
  }
};

const router = createRouter({
  middleware: [errorBoundary],
  routes: {
    home: {
      path: '/',
    },
    error: {
      path: '/error',
    },
  },
  notFound: {
    data: () => ({ message: 'Page not found' }),
  },
});

const views = router.createViewRegistry(
  { error: ErrorPage, home: HomePage },
  { notFound: NotFoundPage },
);

The fallback view is explicit and does not depend on Wayfinder's internal synthetic match name.

For per-route data errors that should render a degraded state instead of redirecting, use onError on the route definition:

ts
const router = createRouter({
  routes: {
    userDetail: {
      path: '/users/:id',
      data: async ({ params }) => fetchUser(params.id),
      onError: (error) => ({ error, user: null }),
    },
  },
});

When onError returns a value, it becomes match.data and the route renders normally (status: 'idle'). The global error boundary is not triggered.

Pitfalls ​

  • An error thrown inside a middleware function before calling next() is not caught by the boundary wrapping that same middleware. It propagates to onError instead.
  • Navigating to /error inside the error boundary must not itself throw, or you risk an infinite loop.
  • If onError itself throws, the router falls through to status: 'error' as usual and the global boundary fires.