[]previous and next page

Redirects, not-found and errors

Throw from beforeLoad or a loader to steer the request.

For agentsUpdated

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.

src/routes/(account)/_layout.tsxtsx
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>
  );
}
src/routes/(account)/settings.tsxtsx
import type { JSX } from "@shadow-js/web";

export default function Settings(): JSX.Element {
  return <h1>Settings</h1>;
}
src/routes/login.tsxtsx
import type { JSX } from "@shadow-js/web";

export default function Login(): JSX.Element {
  return <h1>Sign in</h1>;
}
src/routes/old-about.tsxtsx
import { redirect } from "@shadow-js/dusk";

export const beforeLoad = (): never => {
  throw redirect("/about", { status: 301 });
};
src/routes/broken.tsxtsx
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:

src/routes/gone.tsxtsx
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 beforeLoad ran before the throw), and its notFound takes 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 beforeLoad threw has no data to render with, so it is replaced by its notFound; 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:

src/routes/_layout.tsxtsx
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.

src/routes/render-crash.tsxtsx
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.