Files
2026-03-02 21:16:26 +01:00

4.6 KiB

err-not-found: Handle Not-Found Routes Properly

Priority: HIGH

Explanation

Configure notFoundComponent to handle 404 errors gracefully. TanStack Router provides not-found handling at multiple levels: root, route-specific, and programmatic via notFound(). Proper configuration prevents blank screens and improves UX.

Bad Example

// No not-found handling - shows blank screen or error
const router = createRouter({
  routeTree,
  // Missing defaultNotFoundComponent
})

// Or throwing generic error
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    if (!post) {
      throw new Error('Not found')  // Generic error, not proper 404
    }
    return post
  },
})

Good Example: Root-Level Not Found

// routes/__root.tsx
export const Route = createRootRoute({
  component: RootComponent,
  notFoundComponent: GlobalNotFound,
})

function GlobalNotFound() {
  return (
    <div className="not-found">
      <h1>404 - Page Not Found</h1>
      <p>The page you're looking for doesn't exist.</p>
      <Link to="/">Go Home</Link>
    </div>
  )
}

// router.tsx - Can also set default
const router = createRouter({
  routeTree,
  defaultNotFoundComponent: () => (
    <div>
      <h1>404</h1>
      <Link to="/">Return Home</Link>
    </div>
  ),
})

Good Example: Route-Specific Not Found

// routes/posts/$postId.tsx
import { createFileRoute, notFound } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    if (!post) {
      throw notFound()  // Proper 404 handling
    }
    return post
  },
  notFoundComponent: PostNotFound,  // Custom 404 for this route
  component: PostPage,
})

function PostNotFound() {
  const { postId } = Route.useParams()

  return (
    <div>
      <h1>Post Not Found</h1>
      <p>No post exists with ID: {postId}</p>
      <Link to="/posts">Browse all posts</Link>
    </div>
  )
}

Good Example: Not Found with Data

export const Route = createFileRoute('/users/$username')({
  loader: async ({ params }) => {
    const user = await fetchUser(params.username)
    if (!user) {
      throw notFound({
        // Pass data to notFoundComponent
        data: {
          username: params.username,
          suggestions: await fetchSimilarUsernames(params.username),
        },
      })
    }
    return user
  },
  notFoundComponent: UserNotFound,
})

function UserNotFound() {
  const { data } = Route.useMatch()

  return (
    <div>
      <h1>User @{data?.username} not found</h1>
      {data?.suggestions?.length > 0 && (
        <div>
          <p>Did you mean:</p>
          <ul>
            {data.suggestions.map((username) => (
              <li key={username}>
                <Link to="/users/$username" params={{ username }}>
                  @{username}
                </Link>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  )
}

Good Example: Catch-All Route

// routes/$.tsx - Catch-all splat route
export const Route = createFileRoute('/$')({
  component: CatchAllNotFound,
})

function CatchAllNotFound() {
  const { _splat } = Route.useParams()

  return (
    <div>
      <h1>Page Not Found</h1>
      <p>No page exists at: /{_splat}</p>
      <Link to="/">Go to homepage</Link>
    </div>
  )
}

Good Example: Nested Not Found Bubbling

// Not found bubbles up through route tree
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
  notFoundComponent: PostsNotFound,  // Catches child 404s too
})

// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    if (!post) throw notFound()
    return post
  },
  // No notFoundComponent - bubbles to parent
})

// routes/posts/$postId/comments.tsx
export const Route = createFileRoute('/posts/$postId/comments')({
  loader: async ({ params }) => {
    const comments = await fetchComments(params.postId)
    if (!comments) throw notFound()  // Bubbles to /posts notFoundComponent
    return comments
  },
})

Context

  • notFound() throws a special error caught by nearest notFoundComponent
  • Not found bubbles up the route tree if not handled locally
  • Use defaultNotFoundComponent on router for global fallback
  • Pass data to notFound({ data }) for contextual 404 pages
  • Catch-all routes (/$) can handle truly unknown paths
  • Different from error boundaries - specifically for 404 cases