Error Handling
Furin separates unexpected failures from expected missing content:
error.tsxcatches loader and render failures.not-found.tsxhandles unmatched URLs andnotFound()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:
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
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>
);
}
| Field | Meaning |
|---|---|
error.message | Safe public message chosen by Furin's error normalization |
error.digest | Deterministic 10-character hexadecimal correlation ID |
error.status | HTTP 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:
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 failed | What renders |
|---|---|
A page loader, head() or notFound() in a page | The nearest boundary, inside every layout above it |
| A layout loader | The nearest boundary in a parent directory, inside the layouts above it |
| The root loader, or the document shell during rendering | The 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
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:
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
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.