Redirects, not-found and errors
Throw from beforeLoad or a loader to steer the request.
Throw from beforeLoad or a loader to steer the request. Use redirect and notFound from @shadow-js/dusk
(a root export that is safe in the browser bundle); redirect(to, { status }) is the router's redirect with an HTTP
status attached.
| Thrown | Server response | In the browser |
|---|---|---|
redirect("/login") (302), redirect(to, { status: 301 | 303 | 307 | 308 }) |
That status and a Location header. The first redirect wins; later hops are not followed on the server |
The router navigates to the target |
redirect({ to: "/posts/:id", params: { id: "7" }, search: { tab: "edit" }, hash: "top", status: 308 }) |
The same, with the typed params, search and hash that links take; the pattern's params are checked at compile time and a missing one is a RangeError |
The router navigates to the built location |
notFound(), or a URL no route matches |
Status 404, rendered with the nearest notFound UI (an unmatched URL: the root layout's, see below) |
The same UI |
HttpError(status, message) |
That status; the nearest error UI receives the error |
The nearest error UI |
| Any other error | 500; in production the error shown to the error UI is a generic HttpError whose message is Internal Server Error. The real error goes to onError once |
The error as thrown |
| A component throws while the page renders (see Render errors below) | The thrown HttpError's status, else 500. The route's error UI is in the document; the error goes to onError once. A boundary error after the shell does not change the status |
The error UI; the browser renders the component again, so the error there is the browser's own |
validateSearch throws an HttpError |
That status, as the built-in error document (no route has matched yet, so no error UI) |
The navigation fails |
validateSearch throws anything else |
500, the built-in error document | The navigation fails |
A redirect target must be a path of this site (/login, not //host) or an absolute URL of this origin or
a trusted origin. A redirect to any other origin is a server bug: it answers 500 and
reports a RedirectBlockedError. A redirect-only page needs no component: a route file with only beforeLoad is a
route.
import { Outlet } from "@shadow-js/router";
import { redirect } from "@shadow-js/dusk";
import type { JSX } from "@shadow-js/web";
import { whoami } from "../../server/posts";
export const beforeLoad = async (): Promise<{ viewer: string }> => {
const viewer = await whoami();
if (viewer === null) throw redirect("/login", { status: 303 });
return { viewer };
};
export default function AccountLayout(): JSX.Element {
return (
<div class="account">
<Outlet />
</div>
);
}
import type { JSX } from "@shadow-js/web";
export default function Settings(): JSX.Element {
return <h1>Settings</h1>;
}
import type { JSX } from "@shadow-js/web";
export default function Login(): JSX.Element {
return <h1>Sign in</h1>;
}
import { redirect } from "@shadow-js/dusk";
export const beforeLoad = (): never => {
throw redirect("/about", { status: 301 });
};
import type { JSX } from "@shadow-js/web";
export const loader = (): never => {
throw new Error("password for db is hunter2");
};
export const error = (props: { error: unknown }): JSX.Element => (
<p class="broken">{props.error instanceof Error ? props.error.message : "failed"}</p>
);
export default function Broken(): JSX.Element {
return <p>never rendered</p>;
}
A route that gives up on a request throws notFound(); this one has no notFound of its own, so the layout's
renders in its outlet:
import { notFound } from "@shadow-js/dusk";
import type { JSX } from "@shadow-js/web";
export const loader = (): never => {
throw notFound();
};
export default function Gone(): JSX.Element {
return <p>never rendered</p>;
}
Not found. notFound() thrown by beforeLoad or a loader answers 404 and renders the notFound export of the
nearest route at or above the one that threw. Where that UI goes depends on the route that owns it:
- A layout whose own data loaded stays on screen, with that data (its loader and
beforeLoadran before the throw), and itsnotFoundtakes the place of its outlet. Its chrome (navigation, header) is still there and its loader data is what it was. - A layout whose own loader or
beforeLoadthrew has no data to render with, so it is replaced by itsnotFound; the layouts above it stay with their data. - A route without children is replaced by its
notFound; its ancestors stay.
A typical case is a project page under an app shell: the shell's loader loads the viewer, the project layout's loader
throws notFound() for a project the viewer cannot see, and the project layout's notFound shows inside the shell.
Loaders therefore throw notFound() directly; there is no need to return null for a missing resource.
A URL that no route matches is handled the same way by the root layout: the first top-level route without a path
of its own, which is src/routes/_layout.tsx in a file project. Its notFound renders in its outlet, with status
404, the same on the server and in the browser. Without a notFound there, an unmatched URL gets the built-in 404
document, outside your layouts, and so does a notFound() that no route above can render. A static route such as
about.tsx always wins over the unmatched case.
The root layout of this page exports its notFound next to the layout. This block replaces the file shown in
Layouts:
import { A, Outlet } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";
export const notFound = (): JSX.Element => <p class="missing">Nothing lives here.</p>;
export default function Layout(): JSX.Element {
return (
<div class="site">
<nav>
<A to="/">Home</A> <A to="/posts">Posts</A> <A to="/about">About</A>
</nav>
<Outlet />
</div>
);
}
Render errors. A component that throws while the page renders does not need a loader to fail: the route's error
boundary takes the error, the document is sent with the error UI in place of the component, and the status is the
thrown HttpError's status, or 500 for any other error. The error goes to onError once, as the original error. In
production the error UI receives the same public error a loader failure gives it: an HttpError with a status below
500 keeps its message, anything else shows Internal Server Error. A route without an error UI answers the built-in
500 page, as before.
The status is decided by the shell. A boundary inside a streamed Suspense that fails after the shell is sent does not
change the status: the page stays 200, that boundary shows its fallback, and the error is still reported. An answer with
a render error is never stored by the page cache (see ISR). HEAD answers the status GET answers,
without a body. An ErrorBoundary you write yourself receives the error as thrown; only the route's error UI is given
the public error.
import type { JSX } from "@shadow-js/web";
export default function RenderCrash(): JSX.Element {
throw new Error("the template broke: hunter3");
}
export const error = (props: { error: unknown }): JSX.Element => (
<p class="render-crash">{props.error instanceof Error ? props.error.message : "failed"}</p>
);
A trailing slash that does not match the policy (trailingSlash: "never" by default) is answered with a 308 to the
canonical path. Pages answer GET and HEAD; OPTIONS on a page answers 204 with Allow, any other method 405.
plugin as in Dusk; the app it builds is a production
handler (dev: false), which is why the real error message does not reach the page.