Skip to main content
← All Articles Β· Β· 14 min read

Remix vs TanStack Router vs React Router v7: Full-Stack Routing in 2026

Compare Remix, TanStack Router, and React Router v7 for data loading, mutations, nested layouts, and type safety. Decide which routing solution fits your React app in 2026.

remixtanstack-routerreact-routerreactfull-stackroutingtypescript

Remix vs TanStack Router vs React Router v7: Full-Stack Routing in 2026

React routing has fragmented into three genuinely different philosophies, and the split matters more than it ever has. React Router v7 merged with Remix to create a full-stack framework. TanStack Router rebuilt client-side routing from scratch with end-to-end type safety as the primary design goal. Remix (now the β€œReact Router framework mode”) focuses on progressive enhancement and web platform primitives.

Choosing between them isn’t a minor configuration decision β€” it affects how you load data, handle mutations, structure nested layouts, and think about the server-client boundary. This guide breaks down all three with real code examples, trade-offs, and a decision matrix.


The State of React Routing in 2026

The merger of Remix and React Router in late 2024 created the current landscape:

  • React Router v7 β€” the library, usable standalone in client-only mode
  • React Router framework mode β€” previously β€œRemix”, now the opinionated full-stack layer
  • TanStack Router β€” independent, client-side first, type-safe routing with optional server integration

All three share React as the view layer but diverge sharply on data loading, type safety, and server rendering strategy.


React Router v7: The Foundation Layer

React Router v7 is the base that the other two build on (in the case of Remix/framework mode) or compete with (TanStack Router). In standalone mode, it’s a capable client-side router with file-based routing optional.

Basic Setup

// src/main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <ErrorBoundary />,
    children: [
      { index: true, element: <Home /> },
      {
        path: 'blog',
        element: <BlogLayout />,
        children: [
          { index: true, element: <BlogList />, loader: blogListLoader },
          { path: ':slug', element: <BlogPost />, loader: blogPostLoader },
        ],
      },
    ],
  },
])

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>
)

Data Loading with Loaders

// loaders.ts
import type { LoaderFunctionArgs } from 'react-router'

export async function blogPostLoader({ params }: LoaderFunctionArgs) {
  const post = await fetchPost(params.slug!)
  if (!post) throw new Response('Not Found', { status: 404 })
  return { post }
}

// Component
import { useLoaderData } from 'react-router'

export function BlogPost() {
  const { post } = useLoaderData<typeof blogPostLoader>()
  return <article><h1>{post.title}</h1></article>
}

Mutations with Actions

import { Form, useActionData, redirect } from 'react-router'
import type { ActionFunctionArgs } from 'react-router'

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()
  const title = formData.get('title') as string

  const errors: Record<string, string> = {}
  if (!title) errors.title = 'Title is required'
  if (Object.keys(errors).length > 0) return { errors }

  await createPost({ title })
  return redirect('/blog')
}

export function NewPost() {
  const actionData = useActionData<typeof action>()

  return (
    <Form method="post">
      <input name="title" />
      {actionData?.errors?.title && (
        <span className="error">{actionData.errors.title}</span>
      )}
      <button type="submit">Create Post</button>
    </Form>
  )
}

React Router v7’s loader/action pattern runs on the server in SSR mode and on the client in SPA mode β€” the same code works both ways.


Remix (Framework Mode): Web Standards Full-Stack

Remix in framework mode (formerly Remix v2, now React Router framework mode) adds file-based routing, server rendering, and a strong emphasis on progressive enhancement via native HTML forms.

File-Based Route Structure

app/
  routes/
    _index.tsx           β†’ /
    blog._index.tsx      β†’ /blog
    blog.$slug.tsx       β†’ /blog/:slug
    blog.new.tsx         β†’ /blog/new
    _layout.tsx          β†’ shared layout (no URL segment)
    admin/
      _index.tsx         β†’ /admin
      users.$id.tsx      β†’ /admin/users/:id

Nested Layouts and Outlet

// app/routes/blog.tsx β€” layout for all /blog routes
import { Outlet, NavLink } from 'react-router'
import { useLoaderData } from 'react-router'

export async function loader() {
  const categories = await getCategories()
  return { categories }
}

export default function BlogLayout() {
  const { categories } = useLoaderData<typeof loader>()

  return (
    <div className="blog-layout">
      <aside>
        <nav>
          {categories.map(cat => (
            <NavLink
              key={cat.slug}
              to={`/blog/category/${cat.slug}`}
              className={({ isActive }) => isActive ? 'active' : ''}
            >
              {cat.name}
            </NavLink>
          ))}
        </nav>
      </aside>
      <main>
        <Outlet /> {/* Child route renders here */}
      </main>
    </div>
  )
}

Parallel Data Loading

Remix loads all route loaders in parallel β€” the parent layout’s data loads simultaneously with the child route’s data, not sequentially:

// app/routes/blog.$slug.tsx
export async function loader({ params }: LoaderFunctionArgs) {
  // This runs in parallel with the parent /blog loader
  const [post, relatedPosts] = await Promise.all([
    fetchPost(params.slug!),
    fetchRelatedPosts(params.slug!),
  ])
  return { post, relatedPosts }
}

Streaming with defer

For slow data, Remix can stream partial content with defer:

import { defer } from 'react-router'
import { Await, Suspense } from 'react'

export async function loader({ params }: LoaderFunctionArgs) {
  return defer({
    post: await fetchPost(params.slug!), // Awaited β€” critical content
    comments: fetchComments(params.slug!), // Not awaited β€” streamed
  })
}

export default function BlogPost() {
  const { post, comments } = useLoaderData<typeof loader>()

  return (
    <article>
      <h1>{post.title}</h1>
      <Suspense fallback={<p>Loading comments...</p>}>
        <Await resolve={comments}>
          {(data) => <CommentList comments={data} />}
        </Await>
      </Suspense>
    </article>
  )
}

TanStack Router: End-to-End Type Safety

TanStack Router takes a fundamentally different approach: full TypeScript inference on every route, parameter, search param, and loader return value. It’s client-first but integrates with TanStack Start for SSR.

Route Tree Setup

// src/routeTree.gen.ts (auto-generated by router devtools)
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'

export const Route = createRootRoute({
  component: () => (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/blog">Blog</Link>
      </nav>
      <Outlet />
    </div>
  ),
})
// src/routes/blog/$slug.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/blog/$slug')({
  loader: async ({ params }) => {
    // params.slug is typed as string β€” no casting
    return fetchPost(params.slug)
  },
  component: BlogPost,
})

function BlogPost() {
  // Fully typed β€” TypeScript knows the shape of loaderData
  const post = Route.useLoaderData()
  const { slug } = Route.useParams() // Typed as string

  return <article><h1>{post.title}</h1></article>
}

Type-Safe Search Params

TanStack Router’s killer feature for data-heavy apps is typed search params with validation:

import { createFileRoute } from '@tanstack/react-router'
import { zodValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'

const searchSchema = z.object({
  page: z.number().min(1).default(1),
  q: z.string().optional(),
  tags: z.array(z.string()).optional(),
  sort: z.enum(['date', 'popularity', 'title']).default('date'),
})

export const Route = createFileRoute('/blog')({
  validateSearch: zodValidator(searchSchema),
  loaderDeps: ({ search }) => search,
  loader: async ({ deps }) => {
    // deps is fully typed from searchSchema
    return fetchPosts({
      page: deps.page,
      query: deps.q,
      tags: deps.tags,
      sort: deps.sort,
    })
  },
  component: BlogList,
})

function BlogList() {
  const search = Route.useSearch() // Typed as z.infer<typeof searchSchema>
  const navigate = useNavigate({ from: Route.fullPath })
  const posts = Route.useLoaderData()

  return (
    <div>
      <input
        value={search.q ?? ''}
        onChange={(e) =>
          navigate({ search: (prev) => ({ ...prev, q: e.target.value }) })
        }
      />
      {posts.map(post => <PostCard key={post.id} post={post} />)}
    </div>
  )
}

This is the key differentiator: URL state is fully typed and validated. No URLSearchParams parsing, no manual casting, no bugs from misspelled params.

Route Preloading

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

// Preload data on hover β€” 300ms head start before navigation
<Link
  to="/blog/$slug"
  params={{ slug: post.slug }}
  preload="intent"
>
  {post.title}
</Link>

// Preload immediately β€” for high-probability navigation paths
<Link
  to="/checkout"
  preload="render"
>
  Checkout
</Link>

Side-by-Side Comparison

FeatureRemix / RR FrameworkTanStack RouterReact Router v7
Routing modelFile-based + SSRFile-based + type-safeCode or file-based
Type safetyGood (via types)Excellent (inference)Good (via types)
Search paramsManual parsingSchema-validated, typedManual parsing
Data loadingParallel loadersPer-route loadersLoaders (SSR or SPA)
MutationsForm actionsManual (no built-in)Form actions
SSRFirst-classVia TanStack StartYes
Bundle size~40KB~50KB~25KB
Progressive enhanceYes (native forms)NoPartial
Pending UIuseNavigationuseRouterStateuseNavigation

Decision Matrix

Choose Remix / React Router framework mode when:

  • You want a batteries-included full-stack React framework with SSR
  • Progressive enhancement (works without JS) is a priority
  • Your team is comfortable with web platform primitives (FormData, Request, Response)
  • You’re migrating from a traditional MPA and want to add interactivity gradually

Choose TanStack Router when:

  • Type safety across URL state (params, search) is critical
  • You’re building a complex SPA or dashboard with many URL-driven states
  • You want exhaustive TypeScript inference without workarounds
  • You pair it with TanStack Query for server state management

Choose React Router v7 (standalone) when:

  • You want a stable, well-documented client-side router without the framework opinions
  • You’re adding routing to an existing React app
  • Server rendering isn’t a priority

Summary

The merger of Remix and React Router has clarified the landscape rather than unified it. Remix framework mode is the progressive-enhancement full-stack path. TanStack Router is the type-safe SPA path. React Router v7 remains the solid general-purpose foundation.

The right choice depends on where your complexity lives: in the server/mutation surface (lean toward Remix) or in the URL/client state surface (lean toward TanStack Router).

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.