DEV Community

Cover image for How to Handle 404s and Errors in TanStack Start
Wade Thomas
Wade Thomas

Posted on Edited on AI-assisted

How to Handle 404s and Errors in TanStack Start

A 404 and a 500 look alike to a visitor, but TanStack Router treats them as two different problems. This article builds a global 404, a product not-found page, an error screen and a working retry button in a TanStack Start app.


This article follows the video above, step by step, with all the code.

The starting point

The demo is a small store built with TanStack Start, with Directus as the backend. It has a products list at /products and a detail page at /products/$productId. Both sit inside a pathless layout, _appLayout, which renders the header and an <Outlet />.

Product ids in Directus are UUIDs. The detail route loads one product and renders it, with no error handling at all:

export const Route = createFileRoute('/products/_appLayout/$productId')({
  loader: async ({ params }) => {
    return getProductByID(params.productId)
  },
  component: RouteComponent,
})
Enter fullscreen mode Exit fullscreen mode

getProductByID asks Directus for the product with that id and returns null when nothing matches. The router has no defaultNotFoundComponent and no defaultErrorComponent, and no route has its own handler. Everything you see in the next section is TanStack Router's built-in behaviour.

Four failures before any fix

Here is what the app does with no handling in place. The URLs are the ones I tested in the browser.

URL or situation What you see Kind
/products/abc "Something went wrong!" with a generic message, no header Error (500)
A valid UUID that is not in Directus Blank page, then "Cannot read properties of null", no header Error
/nothinghere A bare "Not Found", no header Not found (404)
Directus is down "Something went wrong!" with "fetch failed", no header Error (500)

The browser console adds a warning that no notFoundComponent was configured, and a failed request in the Network tab. That is the whole experience a visitor gets.

The last column is the useful one. A not found means the thing is not there, and the server answers 404. An error means something broke while loading, and the server answers 500. TanStack Router has a separate handler for each, and the fixes below treat them separately.

The header vanishes on the error rows because nothing in the app catches the error. It reaches the root, which replaces the whole page with a generic screen.

Fix 1: a global 404 for URLs that match nothing

Start with the simplest case. A 404 page is an ordinary component. Create src/components/NotFound.tsx:

import { Link } from '@tanstack/react-router'

export function NotFound() {
  return (
    <div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-8 text-center">
      <h1 className="text-6xl font-bold text-slate-700">404</h1>
      <p className="text-slate-500">This page does not exist.</p>
      <Link to="/" className="rounded bg-slate-700 px-4 py-2 text-white">
        Go home
      </Link>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Use Link and not an <a> tag, so going home is a client-side navigation and does not reload the page. The height is min-h-[60vh] and not min-h-screen, because this component can render inside a layout below a header, and a full screen of height would make the page scroll.

Then add it to the router configuration in src/router.tsx:

import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { NotFound } from './components/NotFound'

export function getRouter() {
  const router = createTanStackRouter({
    routeTree,
    scrollRestoration: true,
    defaultPreload: 'intent',
    defaultPreloadStaleTime: 0,
    defaultNotFoundComponent: NotFound,
  })

  return router
}
Enter fullscreen mode Exit fullscreen mode

defaultNotFoundComponent is what the router renders when no route matches. Visit /nothinghere and you get your 404 with a way home.

Fix 2: a product that does not exist - /products/00000000-0000-4000-8000-000000000000

/products/<some UUID> matches the product route perfectly, so the router cannot know whether that product exists. Only the loader knows, so the loader has to say so. Throw notFound() when Directus returns nothing:

import { createFileRoute, notFound } from '@tanstack/react-router'

export const Route = createFileRoute('/products/_appLayout/$productId')({
  loader: async ({ params }) => {
    const product = await getProductByID(params.productId)
    if (!product) throw notFound()
    return product
  },
  component: RouteComponent,
})
Enter fullscreen mode Exit fullscreen mode

Remember that notFound() is a function, not a component. You throw it, and the router decides which component to show. Two things change. A missing product no longer crashes the component with "Cannot read properties of null". And after that line TypeScript knows product is not null, so the component can use it without a null check.

Reload a UUID that is not in Directus. You now get the global 404 inside the layout, with the header still showing. It says "This page does not exist", but the page does exist. It is the product that does not, and the generic 404 cannot say which product is missing or send the visitor back to the products list.

Give the route its own notFoundComponent:

import { createFileRoute, Link, notFound } from '@tanstack/react-router'

export const Route = createFileRoute('/products/_appLayout/$productId')({
  loader: async ({ params }) => {
    const product = await getProductByID(params.productId)
    if (!product) throw notFound()
    return product
  },
  notFoundComponent: ProductNotFound,
  component: RouteComponent,
})

function ProductNotFound() {
  const { productId } = Route.useParams()

  return (
    <div className="space-y-4 py-12 text-center">
      <h1 className="text-3xl font-bold text-slate-700">Product not found</h1>
      <p className="text-slate-500">
        We could not find a product with the id <code>{productId}</code>.
      </p>
      <Link to="/products" className="inline-block rounded bg-slate-700 px-4 py-2 text-white">
        See all products
      </Link>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Inside a notFoundComponent read the id with Route.useParams(). Do not use useLoaderData there, because the loader threw and returned nothing. The message now fits the situation, and the visitor gets a link back to the products list.

Fix 3: real errors - /products/abc

Now the other half. When something actually throws, such as Directus being down or rejecting a malformed id like abc, nothing in the app catches it. The router falls back to its built-in screen at the root, and the header disappears.

A component given to defaultErrorComponent catches errors for every route that has no errorComponent of its own. It receives two props: error, which is whatever was thrown, and reset, which clears the error boundary. Create src/components/ErrorState.tsx:

import { Link, type ErrorComponentProps } from '@tanstack/react-router'

function getMessage(error: unknown) {
  if (error instanceof Error) return error.message
  return JSON.stringify(error, null, 2) ?? String(error)
}

export function ErrorState({ error, reset }: ErrorComponentProps) {
  console.error(error)

  return (
    <div className="space-y-4 py-12 text-center">
      <h1 className="text-3xl font-bold text-slate-700">Something went wrong</h1>
      <p className="text-slate-500">We hit a problem loading this page.</p>

      {import.meta.env.DEV && (
        <pre className="mx-auto max-w-xl overflow-auto rounded bg-slate-100 p-3 text-left text-sm">
          {getMessage(error)}
        </pre>
      )}

      <div className="flex justify-center gap-3">
        <button onClick={reset} className="rounded bg-slate-700 px-4 py-2 text-white">
          Try again
        </button>
        <Link to="/" className="rounded border px-4 py-2">
          Go home
        </Link>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The getMessage helper exists because error is typed unknown. Anything can be thrown in JavaScript, and the Directus SDK throws plain objects, not Error instances. We log the real error for ourselves, show a friendly message to the visitor, and show the raw text only while developing. Do not show raw error text in production, because it can leak details about your backend.

Register it next to the 404 in src/router.tsx:

import { ErrorState } from './components/ErrorState'

const router = createTanStackRouter({
  routeTree,
  scrollRestoration: true,
  defaultPreload: 'intent',
  defaultPreloadStaleTime: 0,
  defaultNotFoundComponent: NotFound,
  defaultErrorComponent: ErrorState,
})
Enter fullscreen mode Exit fullscreen mode

The error screen now renders inside the layout, with the header still on screen. One thing to expect: for server-side errors the browser receives a generic "An unexpected error occurred" message. The real Directus error is in the terminal where the dev server runs.

An invalid id is an error, not a not-found

/products/abc is not a missing product. Nothing was looked up and nothing was missing, because abc is not a valid product id at all. Today it only fails because Directus rejects the id when the loader runs. The better place to catch it is before the loader, and TanStack Router has a hook for that: params.parse.

params.parse runs when the route matches, before the loader. If it throws, the route goes into an error state, the loader never runs, and your error component renders. In src/routes/products/_appLayout/$productId.tsx, add the pattern above the route and the params option to it:

const UUID_PATTERN =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

export const Route = createFileRoute('/products/_appLayout/$productId')({
  params: {
    parse: (params) => {
      if (!UUID_PATTERN.test(params.productId)) {
        throw new Error('Invalid product id')
      }
      return { productId: params.productId }
    },
    stringify: (params) => params,
  },
  loader: async ({ params }) => {
    const product = await getProductByID(params.productId)
    if (!product) throw notFound()
    return product
  },
  notFoundComponent: ProductNotFound,
  component: RouteComponent,
})
Enter fullscreen mode Exit fullscreen mode

The loader keeps one job, checking that the product exists. The shape of the id is checked in params. Visit /products/abc and you get the error screen with "Invalid product id" in the grey box, and the header stays on screen. A valid UUID that is not in Directus still shows "Product not found".

The grey box only renders in development, because of the import.meta.env.DEV check in ErrorState. On a production build the grey box is not shown. The visitor sees "Something went wrong", the friendly text, and the Try again and Go home buttons, and the real message stays in your logs. Use this split as a rule: validate the shape of an id in params.parse, and check that the thing exists in the loader.

That leaves the question of how to see the error screen on purpose, since the bad id now goes through it only by accident. You break something, which is the next section.

Making "Try again" actually retry

The button we just built does not work yet. To see it, make the Directus requests fail. Open the products list and let it load. In DevTools, open the three-dots menu, then More tools, then Request conditions. Add a blocking rule with the URL pattern your directus URL goes here, and tick its checkbox to switch blocking on. Click a product card. Because this is a client-side navigation, the loader's fetch runs in the browser, Directus is blocked, and the error screen appears.

Untick the checkbox to switch blocking off, then click "Try again". Nothing happens, and the same error stays.

The reason is that reset() only clears the error boundary and renders the same route again. The router still holds the failed loader result, so the loader never runs a second time. To retry for real, tell the router to run the loaders again with router.invalidate(). Update ErrorState:

import { Link, useRouter, type ErrorComponentProps } from '@tanstack/react-router'

export function ErrorState({ error, reset }: ErrorComponentProps) {
  const router = useRouter()
  console.error(error)

  function retry() {
    reset()
    router.invalidate()
  }

  // ...same markup as before, with onClick={retry} on the Try again button
}
Enter fullscreen mode Exit fullscreen mode

useRouter() gives access to the router, and retry does both jobs: reset() clears the error screen and invalidate() re-runs the loaders.

Repeat the test. Click "Go home", tick the checkbox again, and click a product to get the error. Untick it and click "Try again". The product page loads, and the visitor never had to reload.

I blocked the requests on purpose. I did not use the Offline throttling option or edit the Directus URL in code, because both make the dev server reload the page, which clears the error before you can click anything. Start from the products list and click a card. A full page load fetches on the server, where the browser cannot block it.

Conclusion

The whole article comes down to the status code. A 404 means the thing is not there, and a 500 means something broke.

Problem Handler Where it lives
URL matches no route defaultNotFoundComponent router.tsx
Route matches, data is missing notFound() and notFoundComponent The route file
Something throws defaultErrorComponent, or a route's own errorComponent router.tsx
Retry after an error reset() and router.invalidate() The error component

Three rules to take away:

  1. Throw notFound() when the data does not exist.
  2. Let errors be errors. Show a friendly message and a way to recover.
  3. A retry button needs router.invalidate(), not just reset().

Everything here was run in the tutorial app, including the failures. If you follow along, test the bad URLs yourself first, so you see what each handler replaces.

In the next article I cover basic SEO in TanStack Start: page titles and meta tags, a sitemap and a robots.txt file.

Top comments (0)