ยท 4 minutes to read

TanStack Router + TanStack Query (Suspense) for Data Fetching with Pagination & Search

Ebn Sina

๐Ÿš€ TanStack Router + TanStack Query (Suspense) with Pagination & Search

Fetching data in modern React apps should be type-safe, suspense-enabled, and route-aware. In this post, we'll use TanStack Router + TanStack Query to:

โœ… Fetch paginated data via route loader
โœ… Handle loading and error states using Suspense
โœ… Use validateSearch for safe query param parsing
โœ… Sync pagination and search with URL
โœ… Keep things fast, modular, and scalable


๐Ÿงฑ 1. Project Setup

Install the required packages:

npm install @tanstack/react-query @tanstack/react-router zod

๐Ÿง  2. Define the Backend API Function

Your backend returns paginated + filtered results, e.g., /api/users?page=1&limit=10&search=john.

// src/api/users.ts
import axios from 'axios'

export interface User {
  id: string
  name: string
  email: string
}

export interface UserListResponse {
  users: User[]
  total: number
  page: number
  limit: number
}

export async function fetchUsers(params: {
  page: number
  limit: number
  search?: string
}): Promise<UserListResponse> {
  const { data } = await axios.get('/api/users', {
    params,
  })
  return data
}

๐ŸŒ 3. Configure TanStack Query + Router

queryClient.ts

import { QueryClient } from '@tanstack/react-query'

export const queryClient = new QueryClient()

main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import { queryClient } from './lib/queryClient'
import { QueryClientProvider } from '@tanstack/react-query'
import { routeTree } from './routeTree.gen'

const router = createRouter({
  routeTree,
  context: {
    queryClient,
  },
})

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </React.StrictMode>
)

๐Ÿ“„ 4. Route with validateSearch + loader + Suspense

// src/routes/users.tsx
import {
  createFileRoute,
  useSearch,
  useLoaderData,
} from '@tanstack/react-router'
import { fetchUsers } from '../api/users'
import { z } from 'zod'

const SearchSchema = z.object({
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(100).default(10),
  search: z.string().optional(),
})

export const Route = createFileRoute('/users')({
  validateSearch: SearchSchema,
  loaderDeps: ({ search }) => search,
  loader: async ({ context, search }) => {
    return context.queryClient.ensureQueryData({
      queryKey: ['users', search],
      queryFn: () => fetchUsers(search),
    })
  },
  component: UsersPage,
  pendingComponent: () => <div>๐Ÿ”„ Loading users...</div>,
  errorComponent: ({ error }) => <div>โŒ Error: {error.message}</div>,
})

๐Ÿ“ฆ 5. The UsersPage Component (Uses Suspense + React Query)

import { useQuery } from '@tanstack/react-query'
import { fetchUsers } from '../api/users'
import { Route } from './users'

export function UsersPage() {
  const search = Route.useSearch()
  const { data } = useQuery({
    queryKey: ['users', search],
    queryFn: () => fetchUsers(search),
  })

  const { users, total, page, limit } = data!

  return (
    <div>
      <h1>๐Ÿ“„ Users</h1>

      <SearchForm initialSearch={search.search} />

      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <b>{user.name}</b> - {user.email}
          </li>
        ))}
      </ul>

      <PaginationControls page={page} limit={limit} total={total} />
    </div>
  )
}

๐Ÿ” 6. Search & Pagination Components

SearchForm

import { useNavigate } from '@tanstack/react-router'
import { Route } from './users'

export function SearchForm({ initialSearch }: { initialSearch?: string }) {
  const navigate = useNavigate({ from: Route.id })
  const [term, setTerm] = React.useState(initialSearch ?? '')

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        navigate({
          search: (prev) => ({ ...prev, search: term, page: 1 }),
        })
      }}
    >
      <input
        type="text"
        value={term}
        placeholder="Search by name"
        onChange={(e) => setTerm(e.target.value)}
      />
      <button type="submit">Search</button>
    </form>
  )
}

PaginationControls

export function PaginationControls({
  page,
  limit,
  total,
}: {
  page: number
  limit: number
  total: number
}) {
  const navigate = useNavigate({ from: Route.id })
  const totalPages = Math.ceil(total / limit)

  return (
    <div>
      {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
        <button
          key={p}
          disabled={p === page}
          onClick={() => navigate({ search: (s) => ({ ...s, page: p }) })}
        >
          {p}
        </button>
      ))}
    </div>
  )
}

๐Ÿ” UX Bonus: Keep Previous Data While Loading

Enable keepPreviousData in useQuery:

const { data, isFetching } = useQuery({
  queryKey: ['users', search],
  queryFn: () => fetchUsers(search),
  keepPreviousData: true,
})

Show a subtle loading indicator without blanking out the UI:

{isFetching && <small>Updating...</small>}