Error Handling

Furin separates unexpected failures from expected missing content:

  • error.tsx catches loader and render failures.
  • not-found.tsx handles unmatched URLs and notFound() from a loader.

Both conventions are scoped by directory. The nearest authored boundary wins, and a boundary failure bubbles to the next parent boundary.

These page conventions return HTML or Furin's navigation transport. For JSON APIs, use Elysia 2's problem() and class-based .onError(ErrorClass, handler) APIs so failures follow RFC 9457 and Eden can infer each error response:

ts
import { Elysia } from "elysia";

class PostUnavailable extends Error {}

export const postsApi = new Elysia()
  .onError(PostUnavailable, ({ problem }) =>
    problem(503, { detail: "Posts are temporarily unavailable" })
  )
  .get("/posts/:id", ({ params, problem }) => {
    if (!postsService.isAvailable()) throw new PostUnavailable();
    const post = findPost(params.id);
    return post ?? problem("Not Found", { detail: "Post not found" });
  });

Expected API failures use application/problem+json. Furin page notFound() remains a render-control signal rather than an API error, so it intentionally keeps the nearest not-found.tsx behavior.

Error Boundaries

src/pages/blog/error.tsx
import type { ErrorProps } from "@teyik0/furin";

export default function BlogError({ error, reset }: ErrorProps) {
  return (
    <section role="alert">
      <h1>Blog unavailable</h1>
      <p>{error.message}</p>
      <small>Error ID: {error.digest}</small>
      <button onClick={reset}>Try again</button>
    </section>
  );
}
FieldMeaning
error.messageSafe public message chosen by Furin's error normalization
error.digestDeterministic 10-character hexadecimal correlation ID
error.statusHTTP status; thrown Responses and Eden problems retain their status, while other errors become 500
reset()Clears the client boundary and reloads the current route; it is a no-op during server rendering

Thrown Error messages are not exposed directly. Ordinary failures become Something went wrong; the complete error and matching digest stay in server logs. A deliberately thrown Response may supply a public body and status. Throwing an Eden error whose value is an Elysia Problem Detail preserves its HTTP status and uses the problem's detail (or title) as the public message:

ts
const { data: content, error } = await api().content.get();
if (error) throw error;
return { content };

The nearest error.tsx handles this failure, including a 404 API response. Use notFound() when the page itself should render not-found.tsx. Failed ISR renders are not cached.

Without an authored boundary, Furin renders its built-in safe error screen inside the root layout. If the root document itself cannot render, a framework-owned minimal document is the last resort.

Boundary Scope

A directory's error.tsx and not-found.tsx render inside that directory's layout. They handle failures of the directory's pages and deeper layouts, but not of the layout beside them: when blog/_route.tsx fails, the boundary in the parent directory handles it. This is the error.tsx scope of the Next.js App Router.

What failedWhat renders
A page loader, head() or notFound() in a pageThe nearest boundary, inside every layout above it
A layout loaderThe nearest boundary in a parent directory, inside the layouts above it
The root loader, or the document shell during renderingThe root error.tsx / not-found.tsx inside the framework's minimal document

The root boundary also handles any failure that no deeper boundary declares, inside the root layout. A shell render failure cannot be traced to one segment, so the server and the client both render it as a root failure.

The layouts that stay rendered keep their loader data. Furin does not rerun their loaders, and failed loaders and their descendants do not contribute data. Hydration and client navigation render the same tree as the server. For an unmatched URL, a root layout that depends on an unexecuted loader is skipped.

Not Found

src/pages/blog/not-found.tsx
import type { NotFoundProps } from "@teyik0/furin";

export default function BlogNotFound({ error }: NotFoundProps) {
  return (
    <section>
      <h1>Post not found</h1>
      <p>{error.message}</p>
    </section>
  );
}

Trigger it from a loader when the URL matches a page but its resource does not exist:

src/pages/blog/[slug].tsx
import { defineRoute, notFound } from "@teyik0/furin";
import { t } from "elysia";

export const route = defineRoute("/blog/[slug]")
  .config({
    mode: "ssr",
    params: t.Object({ slug: t.String() }),
  })
  .loader(async ({ params }) => {
    const post = await getPost(params.slug);
    if (!post) {
      notFound({
        data: { slug: params.slug },
        message: "This post does not exist.",
      });
    }
    return { post };
  })
  .page(({ post }) => <Post value={post} />);

notFound() never returns. It selects the nearest not-found.tsx, carries serializable message and data, and produces status 404. A root src/pages/not-found.tsx also handles URLs that match no page.

Boundary Placement

text
src/pages/
├── error.tsx
├── not-found.tsx
├── root.tsx
└── blog/
    ├── error.tsx
    ├── not-found.tsx
    ├── _route.tsx
    └── [slug].tsx

Use root conventions for the whole application and add deeper files only where the UI or recovery action is meaningfully different.

Redirects And Responses

Loaders may throw an HTTP redirect Response whose status is 301, 302, 303, 307, or 308 and which includes Location. Furin forwards it as navigation. A redirect status without Location is treated as an error because it is not a valid navigation response.

SPA And Cached Routes

SPA navigation uses the same nearest boundaries as the initial server request. Missing routes and loader notFound() results render inline without replacing the document.

SSG and ISR do not cache failed renders. An ISR miss that fails returns the error response with no-store, leaves the previous cache state untouched, and lets a later request try again. If a streaming failure happens after headers have been flushed, the UI can still switch to a boundary but the original HTTP status can no longer change.

Comments