MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React Router v7: Framework Mode

React Router as a framework: file-based routing, loaders, and server-side rendering.

React RouterRoutingReactFrontend

By MinhVo

Introduction

React Router v7 marks a historic moment in the React ecosystem: the merger of React Router and Remix into a single, unified routing framework. With the release of v7, React Router isn't just a library for client-side routing anymoreβ€”it's a full-stack web framework capable of server-side rendering, file-based routing, data loading, and form mutations. This evolution represents the culmination of years of development by the Remix team, bringing battle-tested patterns from production applications directly into the most widely used routing library in the React ecosystem.

The "framework mode" in React Router v7 is the key differentiator from previous versions. While v6 offered a component-based router for SPAs, v7's framework mode provides an opinionated, convention-over-configuration approach to building React applications. It includes file-based routing, automatic code splitting, server-side rendering, streaming data, and progressive enhancementβ€”all without requiring a separate framework like Next.js.

For developers evaluating their tech stack in 2024-2025, understanding React Router v7's framework mode is crucial. It offers a middle ground between the flexibility of vanilla React Router v6 and the opinionated structure of Next.js. If you've been using React Router for years and want to add server-side capabilities without migrating to a different framework, v7 provides a natural upgrade path.

React Router v7 Framework

Understanding React Router v7: Core Concepts

From Library to Framework

React Router v7 exists in two modes: library mode and framework mode. Library mode is essentially React Router v6 with continued support for <BrowserRouter>, <Routes>, and all the familiar hooks. Framework mode, however, transforms React Router into something fundamentally differentβ€”a full-stack framework with conventions for routing, data loading, server rendering, and deployment.

Framework mode is built on the same principles that made Remix successful: web standards, progressive enhancement, and server-first data loading. Forms submit to server-side functions, data is loaded before components render, and the application works without JavaScript enabled. These principles result in applications that are faster, more resilient, and more accessible.

File-Based Routing

In framework mode, routes are defined by the file system rather than explicit route configuration. Files placed in the app/routes directory automatically become routes, with the file path determining the URL path. This convention eliminates the need for manual route configuration and makes it immediately obvious which file handles which URL.

// File: app/routes/dashboard.settings.tsx
// URL: /dashboard/settings
 
export default function Settings() {
  return <div>Settings Page</div>;
}

The file-based routing system supports dynamic segments (using $ prefix), layout routes (using _ prefix), and pathless routes for wrapping groups of routes without affecting the URL structure.

Server Functions: Loaders and Actions

React Router v7's framework mode brings Remix's loader/action pattern to the forefront. Every route module can export a loader function for data loading and an action function for data mutations. These functions run on the server during SSR and on the client during navigation, providing a seamless data loading experience.

The loader function receives the request object and route parameters, and returns data that's automatically available to the component via useLoaderData. The action function handles form submissions and mutations, receiving the request and returning data that's available via useActionData. Both functions support returning Response objects directly, allowing for redirects, cookies, and custom headers.

Server-Side Rendering

Framework mode provides built-in server-side rendering without any additional configuration. The server renders the initial HTML, streams it to the client, and hydrates the React tree for subsequent navigations. This results in faster initial page loads, better SEO, and improved Core Web Vitals scores.

The SSR implementation supports streaming, which means the server can send the HTML shell immediately while data loaders complete in parallel. This "render as you fetch" pattern eliminates the waterfall problem where the server waits for all data before sending any HTML.

Framework Mode Architecture

Architecture and Design Patterns

Convention-Based File Structure

Framework mode uses a specific file structure that conventions routing, layouts, and error handling:

app/
β”œβ”€β”€ routes/
β”‚   β”œβ”€β”€ _index.tsx              # / (home page)
β”‚   β”œβ”€β”€ about.tsx               # /about
β”‚   β”œβ”€β”€ dashboard.tsx           # /dashboard (layout)
β”‚   β”œβ”€β”€ dashboard._index.tsx    # /dashboard (index)
β”‚   β”œβ”€β”€ dashboard.settings.tsx  # /dashboard/settings
β”‚   β”œβ”€β”€ dashboard.users.$id.tsx # /dashboard/users/:id
β”‚   └── blog.$slug.tsx          # /blog/:slug
β”œβ”€β”€ root.tsx                    # Root layout
β”œβ”€β”€ entry.client.tsx            # Client entry
└── entry.server.tsx            # Server entry

Layout Routes and Nested UI

Layout routes are defined by files that share a prefix without a dot separator. The dashboard.tsx file serves as the layout for all dashboard.* routes, rendering an <Outlet> for child content. This pattern naturally creates nested layouts without any explicit configuration.

Type-Safe Route Modules

Each route module in framework mode is fully typed. The loader's return type flows into the component through useLoaderData, and the action's return type flows through useActionData. TypeScript inference ensures that data access is type-safe without manual type annotations.

import type { Route } from './+types/dashboard.settings';
 
export async function loader({ request }: Route.LoaderArgs) {
  const user = await getUser(request);
  const settings = await getSettings(user.id);
  return { user, settings };
}
 
export default function Settings({ loaderData }: Route.ComponentProps) {
  const { user, settings } = loaderData; // Fully typed!
  return <div>{settings.theme}</div>;
}

Error Handling at the Route Level

Framework mode provides route-level error boundaries through the ErrorBoundary export. Each route can define its own error boundary that catches errors from loaders, actions, and rendering. If a route doesn't define an error boundary, the error bubbles up to the parent route's boundary, eventually reaching the root.

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    );
  }
  
  return (
    <div>
      <h1>Something went wrong</h1>
      <p>{error.message}</p>
    </div>
  );
}

Step-by-Step Implementation

Project Setup

Setting up a React Router v7 framework project uses the official CLI:

npx create-react-router@latest my-app
cd my-app
npm install
npm run dev

This scaffolds a complete project with server-side rendering, file-based routing, and TypeScript support configured out of the box.

Creating Routes

Routes are created by adding files to the app/routes directory:

// app/routes/_index.tsx - Home page
import type { Route } from './+types/_index';
 
export function meta() {
  return [
    { title: 'My App' },
    { name: 'description', content: 'Welcome to my app' },
  ];
}
 
export async function loader() {
  const posts = await getRecentPosts();
  return { posts };
}
 
export default function Home({ loaderData }: Route.ComponentProps) {
  const { posts } = loaderData;
  
  return (
    <main>
      <h1>Welcome</h1>
      <ul>
        {posts.map(post => (
          <li key={post.slug}>
            <Link to={`/blog/${post.slug}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

Implementing Forms with Actions

Forms in framework mode use the standard HTML <form> element with server-side action handling:

// app/routes/contact.tsx
import type { Route } from './+types/contact';
import { redirect } from 'react-router';
 
export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;
  
  // Validate
  const errors: Record<string, string> = {};
  if (!name) errors.name = 'Name is required';
  if (!email) errors.email = 'Email is required';
  if (!message) errors.message = 'Message is required';
  
  if (Object.keys(errors).length > 0) {
    return { errors, values: { name, email, message } };
  }
  
  await sendContactEmail({ name, email, message });
  return redirect('/contact/success');
}
 
export default function Contact({ actionData }: Route.ComponentProps) {
  const errors = actionData?.errors;
  
  return (
    <Form method="post">
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" defaultValue={actionData?.values?.name} />
        {errors?.name && <span className="error">{errors.name}</span>}
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" defaultValue={actionData?.values?.email} />
        {errors?.email && <span className="error">{errors.email}</span>}
      </div>
      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" name="message" defaultValue={actionData?.values?.message} />
        {errors?.message && <span className="error">{errors.message}</span>}
      </div>
      <button type="submit">Send</button>
    </Form>
  );
}

Data Loading with Streaming

Loaders support streaming for non-critical data that shouldn't block the initial render:

// app/routes/dashboard.tsx
import { defer } from 'react-router';
import { Suspense } from 'react';
import { Await } from 'react-router';
 
export async function loader({ request }: Route.LoaderArgs) {
  const user = await getUser(request); // Critical - blocks render
  
  // Non-critical - streams in parallel
  const analyticsPromise = getAnalytics(user.id);
  const notificationsPromise = getNotifications(user.id);
  
  return defer({
    user, // Already resolved
    analytics: analyticsPromise, // Will resolve later
    notifications: notificationsPromise, // Will resolve later
  });
}
 
export default function Dashboard({ loaderData }: Route.ComponentProps) {
  return (
    <div>
      <h1>Welcome, {loaderData.user.name}</h1>
      
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Await resolve={loaderData.analytics}>
          {(analytics) => <AnalyticsChart data={analytics} />}
        </Await>
      </Suspense>
      
      <Suspense fallback={<NotificationsSkeleton />}>
        <Await resolve={loaderData.notifications}>
          {(notifications) => <NotificationList items={notifications} />}
        </Await>
      </Suspense>
    </div>
  );
}

Data Loading Pattern

Real-World Use Cases and Case Studies

Use Case 1: Content-Heavy Blog or Documentation Site

React Router v7's framework mode excels for content sites that need SEO, fast initial loads, and dynamic content. The SSR ensures search engines can crawl all content, while streaming data loading provides instant page transitions. File-based routing maps naturally to content hierarchiesβ€”blog posts, documentation sections, and landing pages each get their own route file.

The loader pattern is particularly powerful here: each page's content can be fetched from a CMS or markdown files at build time or request time, with the component receiving fully resolved data. Combined with the meta export for per-page SEO tags, this creates a complete content management solution without a dedicated CMS framework.

Use Case 2: SaaS Dashboard with Authentication

SaaS applications with complex authentication flows benefit from framework mode's server-side loaders and actions. Login, registration, and password reset forms use server actions for validation and session management. Route guards are implemented as loader functions that check authentication before returning data, redirecting unauthenticated users to the login page.

The nested layout system is perfect for dashboards with sidebars, top navigation, and content areas. Each section (analytics, settings, users) gets its own route file sharing the dashboard layout, with the layout handling common elements like navigation and breadcrumbs.

Use Case 3: E-Commerce Platform

E-commerce platforms need server-side rendering for SEO, fast product pages, and secure checkout flows. React Router v7 handles all three: product pages are server-rendered with structured data for search engines, loaders prefetch related products and reviews, and form actions handle checkout with server-side validation and payment processing.

The progressive enhancement model means the checkout form works without JavaScriptβ€”important for users on slow connections or with JavaScript disabled. Server actions validate payment information, prevent duplicate submissions, and handle errors gracefully, all while the client-side code enhances the experience with real-time validation and optimistic UI updates.

Use Case 4: Multi-Tenant Application

Multi-tenant applications where each tenant has a subdomain or path prefix can leverage React Router v7's middleware and route configuration. The root loader can determine the tenant from the request URL, load tenant-specific configuration, and pass it down through the component tree. This pattern is common in B2B SaaS applications where each customer has a customized experience.

Best Practices for Production

  1. Keep Loaders Focused: Each loader should fetch only the data needed for its route. Avoid fetching data for child routes in parent loadersβ€”let each route handle its own data requirements. This enables parallel loading and streaming.

  2. Use Streaming for Non-Critical Data: Wrap non-critical data in defer() and use <Suspense> with <Await> to stream it in parallel. Critical data that affects the initial render should be awaited in the loader.

  3. Implement Proper Error Boundaries: Every route should export an ErrorBoundary that handles both Response errors (404s, 500s) and unexpected errors (JavaScript exceptions). This ensures users always see a meaningful error page instead of a blank screen.

  4. Leverage Progressive Enhancement: Forms should work without JavaScript enabled. Use standard <Form method="post"> elements with server-side action handling. Client-side enhancements (real-time validation, optimistic UI) should be layered on top.

  5. Use Type-Safe Route Modules: Import the generated types from ./+types/[route] for full type safety in loaders, actions, and components. This catches type errors at compile time and improves the developer experience.

  6. Cache Loader Data Appropriately: Use Cache-Control headers in loaders to control how data is cached. Static content can be cached aggressively, while dynamic content should use short cache times or no-cache directives.

  7. Optimize Bundle Size: Framework mode automatically code-splits by route. Avoid importing large libraries in root-level files that would increase the shared bundle. Keep route-specific dependencies in route files.

  8. Use Headers for Security: Export a headers function from routes to set security headers like CSP, X-Frame-Options, and HSTS. This ensures security policies are applied consistently across all routes.

Common Pitfalls and Solutions

PitfallImpactSolution
Mixing library and framework modeConfusion about which APIs to useCommit to framework mode for new projects; use library mode only for existing v6 apps
Forgetting to handle loader errorsUnhandled promise rejections crash SSRAlways wrap async operations in try/catch; return proper error responses
Client-only code in loadersSSR crashes on browser APIsCheck typeof window or use environment guards for client-only code
Over-fetching in loadersSlow initial page loadsFetch only what the route needs; use defer() for non-critical data
Not using progressive enhancementApplication broken without JSUse standard <Form> elements; don't rely on preventDefault in event handlers
Incorrect file naming for routesRoutes don't match expected URLsFollow the file naming conventions strictly; use dots for URL segments, underscores for pathless layouts
Large root bundleSlow initial page loadMove route-specific code to route files; keep root exports minimal
Missing meta tagsPoor SEO and social sharingAlways export meta function with title, description, and Open Graph tags

Performance Optimization

React Router v7's framework mode includes several built-in performance optimizations. Server-side rendering provides fast First Contentful Paint, streaming reduces Time to Interactive, and automatic code splitting minimizes JavaScript bundle size.

Streaming SSR with Data Deferral

// Deferring non-critical data for faster TTFB
export async function loader() {
  return defer({
    // Critical: blocks rendering
    user: await getCurrentUser(),
    
    // Non-critical: streams when ready
    recommendations: getRecommendations(),
    activity: getRecentActivity(),
  });
}

Client-Side Caching with shouldRevalidate

Control when loaders re-run during client-side navigations:

export function shouldRevalidate({ 
  currentUrl, 
  nextUrl, 
  defaultShouldRevalidate 
}) {
  // Only revalidate if the search params changed
  if (currentUrl.searchParams.toString() !== nextUrl.searchParams.toString()) {
    return true;
  }
  // Don't revalidate for same-route navigations
  return false;
}

Use the prefetch prop on <Link> to preload route data and modules on hover or viewport intersection:

<Link to="/dashboard" prefetch="intent">
  Dashboard
</Link>

Comparison with Alternatives

FeatureReact Router v7Next.js App RouterRemix v2
Routing ModelFile-based + LibraryFile-basedFile-based
Server ComponentsOptionalFirst-classNo (loaders instead)
Data LoadingLoaders + ActionsServer Components + fetchLoaders + Actions
SSRBuilt-in streamingBuilt-in streamingBuilt-in streaming
Progressive EnhancementFirst-classLimitedFirst-class
Bundle Size~15KB frameworkFramework included~20KB
Migration PathFrom React Router v6From Pages RouterStandalone
DeploymentAny Node.js hostVercel-optimizedAny Node.js host
CommunityMassive (React Router users)Massive (Next.js users)Growing

Advanced Patterns and Techniques

Custom Server Entry Point

Framework mode allows customizing the server entry for advanced SSR patterns:

// app/entry.server.tsx
import { PassThrough } from 'node:stream';
import type { AppLoadContext, EntryContext } from 'react-router';
import { createReadableStreamFromReadable } from '@react-router/node';
import { RemixServer } from 'react-router';
import { renderToPipeableStream } from 'react-dom/server';
 
export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  routerContext: EntryContext,
  loadContext: AppLoadContext
) {
  return new Promise((resolve, reject) => {
    const { pipe, abort } = renderToPipeableStream(
      <RemixServer context={routerContext} url={request.url} />,
      {
        onShellReady() {
          const body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);
          
          responseHeaders.set('Content-Type', 'text/html');
          
          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            })
          );
          
          pipe(body);
        },
        onShellError(error) {
          reject(error);
        },
        onError(error) {
          responseStatusCode = 500;
          console.error(error);
        },
      }
    );
    
    setTimeout(abort, 5000);
  });
}

Route Middleware Pattern

While React Router v7 doesn't have built-in middleware like Next.js, you can implement middleware-like patterns using the root loader and context:

// app/root.tsx
export async function loader({ request }: Route.LoaderArgs) {
  const session = await getSession(request);
  const user = session.get('user');
  
  // Set common headers
  const headers = new Headers();
  headers.set('X-Request-Id', crypto.randomUUID());
  
  return { user, isAuthenticated: !!user }, { headers };
}

Parallel Route Loading

Loaders for all matching routes run in parallel by default. To optimize, keep loaders independent and avoid parent-child data dependencies:

// Parent loader - independent
export async function loader() {
  return { layout: await getLayoutData() };
}
 
// Child loader - also independent, runs in parallel with parent
export async function loader({ params }) {
  return { content: await getContent(params.slug) };
}

Testing Strategies

Unit Testing Route Modules

import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { render, screen, waitFor } from '@testing-library/react';
 
test('loads and displays user data', async () => {
  const mockUser = { id: '1', name: 'John Doe' };
  
  const router = createMemoryRouter([
    {
      path: '/users/:id',
      loader: () => mockUser,
      element: <UserProfile />,
    },
  ], { initialEntries: ['/users/1'] });
  
  render(<RouterProvider router={router} />);
  
  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });
});

Integration Testing Actions

test('form submission creates a new user', async () => {
  const mockAction = jest.fn().mockResolvedValue({ success: true });
  
  const router = createMemoryRouter([
    {
      path: '/users/new',
      action: mockAction,
      element: <CreateUserForm />,
    },
  ], { initialEntries: ['/users/new'] });
  
  render(<RouterProvider router={router} />);
  
  await userEvent.type(screen.getByLabelText('Name'), 'Jane Doe');
  await userEvent.type(screen.getByLabelText('Email'), 'jane@example.com');
  await userEvent.click(screen.getByText('Create'));
  
  await waitFor(() => {
    expect(mockAction).toHaveBeenCalledWith(
      expect.objectContaining({
        request: expect.any(Request),
      })
    );
  });
});

E2E Testing with Playwright

import { test, expect } from '@playwright/test';
 
test('navigates through dashboard', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.locator('h1')).toHaveText('Dashboard');
  
  await page.click('text=Settings');
  await expect(page).toHaveURL('/dashboard/settings');
  await expect(page.locator('h1')).toHaveText('Settings');
  
  await page.click('text=Profile');
  await expect(page).toHaveURL('/dashboard/profile');
});

Future Outlook

React Router v7 represents the beginning of a new era for the React Router ecosystem. The merger with Remix means that future development will be guided by the same team that built both projects, ensuring consistency and continued innovation.

The roadmap includes improved TypeScript integration with auto-generated route types, enhanced streaming capabilities, and better developer tooling. The React Server Components integration is also being explored, which would allow React Router v7 to leverage server components for even more efficient rendering.

The community adoption is accelerating rapidly. With React Router's existing user base of millions of developers and Remix's production-tested patterns, v7 is positioned to become a dominant framework choice alongside Next.js and other React meta-frameworks.

For teams currently using React Router v6, the migration to v7's framework mode represents a significant architectural shiftβ€”but one that unlocks server-side rendering, better performance, and a more robust development experience. The library mode ensures backward compatibility while you evaluate the framework mode for new features and projects.

Conclusion

React Router v7's framework mode transforms the most popular React routing library into a full-stack web framework. By combining React Router's intuitive routing model with Remix's server-first data loading patterns, it provides a powerful platform for building modern web applications.

Key takeaways:

  1. Framework mode and library mode coexist β€” you can adopt framework mode incrementally while keeping existing v6 routes working
  2. File-based routing eliminates boilerplate β€” the file system is the route configuration, making it easy to understand and maintain
  3. Loaders and actions handle all data needs β€” server-side data loading and mutations are first-class citizens
  4. Progressive enhancement ensures resilience β€” forms work without JavaScript, providing a baseline experience for all users
  5. Streaming SSR optimizes performance β€” critical data blocks rendering while non-critical data streams in parallel
  6. Type safety comes built-in β€” generated route types provide full type inference for loaders, actions, and components

Whether you're building a new application or upgrading an existing React Router project, v7's framework mode offers a compelling combination of developer experience, performance, and web standards compliance that's worth serious consideration.