ZyVOP Logo
Content That Connects
SeriesAI NewsCategoriesWrite for Us
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Badges
  • Write Article
  • Newsletter

Company

  • About Us
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeRouting with TanStack Router: Treat the URL Like Application State

Routing with TanStack Router: Treat the URL Like Application State

Maksym Kuzmitskyi (MaximusFT)
Maksym Kuzmitskyi (MaximusFT)Staff Frontend Engineer
July 31, 2026
8 min read
1 views
Routing with TanStack Router: Treat the URL Like Application State
#react#TanStack Router#Routing#TypeScript#React Playbook

Routing with TanStack Router: Treat the URL Like Application State

The policy list bug started as a support ticket.

An agent filtered policies by status, page, and renewal date. She copied the URL and sent it to another agent. The second agent opened it and saw a different list.

Same page. Same product. Different state.

The filters were not in the URL. The page number lived in local component state. The selected tab lived in a store. The search box was debounced into a fetch call, but never synchronized back to navigation. The route looked shareable. It wasn't.

Honestly, that bug changed how I think about routing.

Routing is not just "which component renders for this path." That is the small version. In product software, routing is about preserving intent. What screen is the user on? Which record? Which filters? Which tab? Which page? Can they reload and get the same view? Can they send the link to someone else and trust it?

I've seen teams treat the URL as decoration. Then they spend months rebuilding browser behavior manually.

TanStack Router gives React apps a better option: typed routes, typed params, typed search, loaders, redirects, preloading, and integration with TanStack Query. The point is not fancy routing. The point is making navigation state explicit.

A URL is not a string you push into history. It is part of your application's state model.

This is article #6 in the React Playbook series. We already covered the stack, TypeScript patterns, code structure, error handling, and TanStack Query. Now we connect those pieces through routing.

Why TanStack Router

React Router is familiar. It works. I used it for years.

The reason I reach for TanStack Router in new TypeScript-heavy apps is not that React Router is bad. It is that TanStack Router makes more route mistakes impossible.

With TanStack Router, routes are registered into a typed route tree. That means:

  • <Link to="/policies/$policyId"> knows which params are required.

  • useParams({ from: '/policies/$policyId' }) returns the right param type.

  • useSearch({ from: '/policies' }) returns validated search state.

  • navigate({ to, params, search }) is checked against the actual route.

  • Loaders can use route context and params safely.

The difference is subtle until a route changes.

Rename $policyId to $id, and TypeScript tells you everywhere the old param is still used. Remove a search param, and navigation calls fail to compile. That is the kind of friction I want.

Not because I enjoy types for their own sake.

Because broken links and stale URL assumptions are production bugs.

File-Based or Code-Based Routes?

TanStack Router supports both file-based and code-based routing.

For large teams, I like file-based routing. It creates a predictable structure, works well with route code splitting, and keeps route files close to pages.

For teaching and small early projects, code-based routing is easier to see in one place.

This article uses a file-based shape because it is what I would use once the app grows:

src/
  routes/
    __root.tsx
    index.tsx
    policies/
      index.tsx
      $policyId.tsx
      $policyId.edit.tsx
  routeTree.gen.ts

In an FSD-style project, I still keep page UI in pages/ and let route files wire it:

src/
  routes/
    policies/
      index.tsx
      $policyId.tsx
  pages/
    policy-list/
      PolicyListPage.tsx
    policy-detail/
      PolicyDetailPage.tsx

Route files are integration points. They connect URL shape, loader behavior, search validation, and page components. They should not become business logic containers.

The Root Route

The root route is where app-level layout and route context begin.

// src/routes/__root.tsx
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
import type { QueryClient } from '@tanstack/react-query';
import { RootLayout } from '@/app/RootLayout';

interface RouterContext {
  queryClient: QueryClient;
  auth: AuthSession | null;
}

export const Route = createRootRouteWithContext<RouterContext>()({
  component: RootRoute,
});

function RootRoute() {
  return (
    <RootLayout>
      <Outlet />
    </RootLayout>
  );
}

That context matters. Later, route loaders can access queryClient without importing a singleton. Auth checks can use the current session. Tests can provide a different context.

Then create the router:

// src/app/router.ts
import { createRouter } from '@tanstack/react-router';
import { routeTree } from '@/routeTree.gen';
import { queryClient } from './query-client';
import { authSession } from './auth-session';

export const router = createRouter({
  routeTree,
  context: {
    queryClient,
    auth: authSession,
  },
});

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

The Register declaration is the bridge that gives the rest of the app type-safe navigation. Skip it and you lose much of the point.

Route Params Should Be Boring

A policy detail route:

// src/routes/policies/$policyId.tsx
import { createFileRoute } from '@tanstack/react-router';
import { PolicyDetailPage } from '@/pages/policy-detail';

export const Route = createFileRoute('/policies/$policyId')({
  component: PolicyDetailRoute,
});

function PolicyDetailRoute() {
  const { policyId } = Route.useParams();

  return <PolicyDetailPage policyId={policyId} />;
}

policyId is typed from the route path. No manual generic. No as string. No "I hope this param exists."

Navigation is typed too:

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

export function PolicyRow({ policy }: { policy: Policy }) {
  return (
    <Link to="/policies/$policyId" params={{ policyId: policy.id }}>
      {policy.policyNumber}
    </Link>
  );
}

If you forget policyId), TypeScript complains. If you pass idinstead ofpolicyId`, TypeScript complains. That is boring in the best possible way.

Search Params Are Product State

The policy list route is where TanStack Router starts to feel different.

Filters belong in the URL:

/policies?status=active&page=2&query=miller

Not because URLs are pretty. Because users reload pages, bookmark views, share links, and go back in browser history.

Define the search shape:

// src/routes/policies/index.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { PolicyListPage } from '@/pages/policy-list';

const policyListSearchSchema = z.object({
  status: z.enum(['active', 'expired', 'pending']).optional(),
  page: z.number().int().positive().catch(1),
  query: z.string().catch(''),
});

export const Route = createFileRoute('/policies/')({
  validateSearch: policyListSearchSchema,
  component: PolicyListRoute,
});

function PolicyListRoute() {
  const search = Route.useSearch();

  return <PolicyListPage filters={search} />;
}

Now search.page is a number. Not a string you parsed manually. Not string | undefined. A number.

This matters because search params are the easiest place for type lies to enter an app. The browser gives you strings. Your product wants domain state. TanStack Router gives you a validation boundary.

Updating Search Without Losing State

When the user changes a filter:

import { useNavigate } from '@tanstack/react-router';

export function PolicyFilters({ filters }: { filters: PolicyListFilters }) {
  const navigate = useNavigate({ from: '/policies/' });

  return (
    <StatusSelect
      value={filters.status}
      onChange={(status) => {
        navigate({
          search: (prev) => ({
            ...prev,
            status,
            page: 1,
          }),
        });
      }}
    />
  );
}

Notice the page: 1.

Changing status should reset pagination. This is a product rule. Put it where navigation state changes. Otherwise you end up on page 7 of a filter that only has one page and someone files a "missing policies" bug.

For pagination:

export function PolicyPagination({ page }: { page: number }) {
  const navigate = useNavigate({ from: '/policies/' });

  return (
    <Pagination
      page={page}
      onPageChange={(nextPage) => {
        navigate({
          search: (prev) => ({
            ...prev,
            page: nextPage,
          }),
        });
      }}
    />
  );
}

No local useState. The URL is the state.

Connecting Router to Query

Article #5 covered query options. Routing is where they pay off.

// src/routes/policies/index.tsx
export const Route = createFileRoute('/policies/')({
  validateSearch: policyListSearchSchema,
  loaderDeps: ({ search }) => ({ search }),
  loader: ({ context, deps }) =>
    context.queryClient.ensureQueryData(policiesQueryOptions(deps.search)),
  component: PolicyListRoute,
});

The route validates search. The loader uses validated search to warm the query cache. The page subscribes to the same query:

function PolicyListRoute() {
  const search = Route.useSearch();
  const policiesQuery = useQuery(policiesQueryOptions(search));

  return <PolicyListPage filters={search} policiesQuery={policiesQuery} />;
}

One data definition. Two usages.

That is the shape I want: route loaders handle navigation-level data readiness; components still use Query for cache subscription, background refetching, and UI state.

Don't fetch in the loader and pass raw data into the component while also using Query elsewhere. That creates two data paths. Use the loader to fill the cache.

Detail Routes and Not Found

For detail pages:

// src/routes/policies/$policyId.tsx
import { createFileRoute, notFound } from '@tanstack/react-router';
import { policyDetailQueryOptions } from '@/entities/policy';
import { isNotFoundError } from '@/shared/api';
import { PolicyDetailPage } from '@/pages/policy-detail';

export const Route = createFileRoute('/policies/$policyId')({
  loader: async ({ context, params }) => {
    try {
      return await context.queryClient.ensureQueryData(
        policyDetailQueryOptions(params.policyId),
      );
    } catch (error) {
      if (isNotFoundError(error)) throw notFound();
      throw error;
    }
  },
  component: PolicyDetailRoute,
  notFoundComponent: PolicyNotFound,
  errorComponent: PolicyRouteError,
});

function PolicyDetailRoute() {
  const { policyId } = Route.useParams();
  const policyQuery = useQuery(policyDetailQueryOptions(policyId));

  return <PolicyDetailPage policy={policyQuery.data} />;
}

The route decides whether the page can exist. A missing policy is not the same as a temporary server failure. This lines up with the error-handling model from article #4.

Route Guards with beforeLoad

Some routes require authentication or permissions.

Use beforeLoad for route-level guards:

// src/routes/admin.tsx
import { createFileRoute, redirect } from '@tanstack/react-router';
import { AdminPage } from '@/pages/admin';

export const Route = createFileRoute('/admin')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth) {
      throw redirect({
        to: '/login',
        search: {
          redirectTo: location.href,
        },
      });
    }

    if (context.auth.role !== 'admin') {
      throw redirect({ to: '/policies' });
    }
  },
  component: AdminPage,
});

This is not a replacement for backend authorization. Never trust the frontend for security.

But it is the right place to keep users out of UI they cannot use. It also preserves intent: after login, send them back to where they tried to go.

Preloading on Intent

Routing should feel instant when intent is clear.

TanStack Router can preload routes through links. TanStack Query can prefetch query data. Use both deliberately.

export function PolicyRow({ policy }: { policy: Policy }) {
  return (
    <Link
      to="/policies/$policyId"
      params={{ policyId: policy.id }}
      preload="intent"
    >
      {policy.policyNumber}
    </Link>
  );
}

When the user hovers or focuses the link, the router can prepare the destination. If your route loader calls ensureQueryData, the query cache warms too.

That is the clean integration: link intent -> route preload -> loader -> query cache.

The user clicks, and the page is already halfway there.

Use Route APIs Inside Route Components

TanStack Router gives each route a Route object. Use it.

function PolicyListRoute() {
  const search = Route.useSearch();
  const navigate = Route.useNavigate();

  // ...
}

This is more precise than global hooks when you know which route you are in. The types are anchored to that route. Refactors stay local.

In leaf page components, I still prefer passing explicit props:

function PolicyListRoute() {
  const search = Route.useSearch();
  const policiesQuery = useQuery(policiesQueryOptions(search));

  return <PolicyListPage filters={search} policiesQuery={policiesQuery} />;
}

Why not let PolicyListPage call Route.useSearch() directly?

Sometimes that is fine. But I like keeping page components easier to test and less coupled to router internals. The route knows about routing. The page knows about rendering the product view.

Don't Hide Navigation in Random Helpers

One pattern I avoid:

export function openPolicy(policyId: string) {
  router.navigate({ to: '/policies/$policyId', params: { policyId } });
}

It looks convenient. It spreads navigation decisions into random helpers. Over time, you get hidden redirects, surprising history behavior, and navigation that bypasses route-local search state.

Prefer explicit links and route-local navigation. If a navigation pattern repeats, create a component:

export function PolicyLink({
  policyId,
  children,
}: {
  policyId: string;
  children: React.ReactNode;
}) {
  return (
    <Link to="/policies/$policyId" params={{ policyId }} preload="intent">
      {children}
    </Link>
  );
}

Now navigation stays visible in the UI tree.

The Review Checklist

When I review routing code, I ask:

  • Is shareable product state in the URL?

  • Are search params validated at the route boundary?

  • Do search changes preserve or reset related state intentionally?

  • Do route loaders reuse TanStack Query options instead of inventing separate fetch paths?

  • Are detail routes handling not-found separately from server errors?

  • Are protected routes guarded in beforeLoad?

  • Are links using typed to, params, and search instead of string concatenation?

  • Is preloading used where user intent is clear?

  • Are page components receiving explicit props when that keeps them easier to test?

Most routing bugs are not about paths. They are about state that should have been in the URL but wasn't.

What Comes Next

With Query and Router in place, the next question is client state: the data that does not live on the server but still becomes too complex for useState.

Article #7: state management with Reatom — why I use it in CRM-scale React apps, where it fits next to TanStack Query, and why I don't reach for Zustand by default in this particular stack.


After the policy-list support ticket, we moved filters, page, and search into route search params. The bug disappeared. More importantly, the product started behaving like a web app again. Refresh worked. Back worked. Shared links worked. QA could send exact states to developers instead of screenshots and vague reproduction steps.

That is what routing should do.

Not just render components.

Preserve intent.

If your React app has pages that look shareable but aren't, start with the URL. The fix is often not another store. It is admitting that the route already owns more state than your code says it does.

Maksym Kuzmitskyi (MaximusFT)

Maksym Kuzmitskyi (MaximusFT)

Staff Frontend Engineer

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Related Posts

How to Make a Product Demo Video in React

This is a build log for a 30-second product demo video, written entirely in React and rendered to an .mp4 you can drop on a landing page, post to X, or attach t...

Read article

Introducing Mat Expressive: Material 3 Expressive for Angular Material

What is Mat Expressive? Mat Expressive is a library of styles, directives, and a few new components that sit on top of Angular Material. It's not a fork and not...

Read article

Why I Validate Angular Compatibility Using the Published npm Package (Not the Source Code)

Most Angular libraries claim compatibility across multiple Angular versions—but how many actually verify it? Here's why I stopped testing my source code and started validating the packaged npm artifact that users really install.

Read article

React Performance Optimization

I joined a large Canadian automotive company as one of three senior frontend engineers. The site was public-facing — a product catalog where users browsed vehic...

Read article

Type-Safe React Architecture

I remember the exact moment I realized I couldn't go back. I had been writing TypeScript for a while, got used to it, and then joined a project that was pure Ja...

Read article