Routing in React with React Router and TypeScript – Part 5: Architecture and Best Practices

Bring everything together with a production-ready architecture for React Router + TypeScript apps built with Vite. Learn about feature-first structure, lazy routes, testing, and common pitfalls.

FSI
Full Stack Insights
6 min read...

In the first four parts of this series, we built a progressively more realistic React Router + TypeScript application on top of a Vite starter:

  1. Part 1 – Fundamentals and basic pages
  2. Part 2 – Nested routes and dashboard layout
  3. Part 3 – Data loading and CRUD flows
  4. Part 4 – Auth, protected routes, and roles

In Part 5, we'll step back and talk about architecture and best practices:

  • Feature-first folder structure
  • Lazy-loaded routes and code splitting
  • Centralized route configuration
  • Testing navigation and protected routes
  • Common pitfalls to avoid

The goal is to help you adapt the example into a production-scale app.


1. Feature-first folder structure

As an app grows, organizing by technical layer (components/, routes/, hooks/, services/) often leads to file-chasing. Instead, many teams adopt a feature-first structure.

Here's a typical evolution of our existing structure:

src/
  app/
    main.tsx
    router.tsx
    AppLayout.tsx
  features/
    auth/
      AuthContext.tsx
      authTypes.ts
    dashboard/
      DashboardLayout.tsx
      DashboardLayout.css
      DashboardOverviewPage.tsx
      AdminPage.tsx
    projects/
      ProjectsPage.tsx
      ProjectDetailsPage.tsx
    tasks/
      taskTypes.ts
      taskStore.ts
      tasksRoutes.tsx
  shared/
    MainNav.tsx
    MainNav.css

Key ideas:

  • app/ contains composition root code: router, layout, initialization.
  • features/ contains independent vertical slices (auth, dashboard, tasks, projects).
  • shared/ holds generic UI reused across features.

With this in place, your imports become more intentional, e.g. import { DashboardLayout } from "../features/dashboard/DashboardLayout";.

You don't have to adopt this exact structure, but aim for grouping by domain instead of technology.


2. Centralizing and typing route configuration

In earlier parts, we defined our route tree directly in router.tsx. As apps grow, it can be helpful to:

  • Keep a single source of truth for routes
  • Export named route helpers to avoid hard-coded strings

For example, create src/app/routesConfig.ts:

export const routes = {
  home: () => "/",
  about: () => "/about",
  contact: () => "/contact",
  login: () => "/login",
  dashboard: {
    root: () => "/dashboard",
    projects: () => "/dashboard/projects",
    projectDetails: (id: string) => `/dashboard/projects/${id}`,
    tasks: () => "/dashboard/tasks",
    taskDetails: (id: string) => `/dashboard/tasks/${id}`,
    admin: () => "/dashboard/admin",
  },
} as const;

Now your UI can use:

import { routes } from "../app/routesConfig";

<Link to={routes.dashboard.taskDetails(task.id)}>{task.title}</Link>

This reduces the risk of typos and makes refactoring URLs easier.


3. Lazy-loaded routes and code splitting

Vite and React support code splitting out of the box. React Router can lazy-load route components so users only download the code they need.

For example, in src/app/router.tsx you can use React.lazy or React Router's lazy() helper to code-split route components. Here's a simple React.lazy example:

import React from "react";
import { createBrowserRouter } from "react-router-dom";
import { AppLayout } from "./AppLayout";
import { NotFoundPage } from "../shared/NotFoundPage";

const HomePage = React.lazy(() => import("../features/home/HomePage"));
const DashboardLayout = React.lazy(
  () => import("../features/dashboard/DashboardLayout")
);

export const router = createBrowserRouter([
  {
    path: "/",
    element: (
      <React.Suspense fallback={<div>Loading...</div>}>
        <AppLayout />
      </React.Suspense>
    ),
    errorElement: <NotFoundPage />,
    // children using lazy-loaded elements
  },
]);

Alternatively, React Router v6.8+ exposes a lazy() helper you can use in the route object to defer module loading; consult the React Router docs for the exact API on your version.

Alternatively, use lazy() from React Router v6.4+ for route objects. The goal is to load heavy feature bundles (like /dashboard) only when users navigate there.

Best practices:

  • Lazy-load feature routes, not tiny leaf components.
  • Provide a sensible fallback (spinner, skeleton) via Suspense.
  • Keep the number of simultaneous lazy imports reasonable to avoid waterfall loading.

4. Testing navigational flows

Routing is core behavior; it deserves tests. You can test React Router apps using your favorite testing library (e.g. React Testing Library + Jest/Vitest).

A simple example for RequireAuth with React Testing Library:

import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { AuthProvider } from "../features/auth/AuthContext";
import { RequireAuth } from "../routes/RequireAuth";

function renderWithAuth(ui: React.ReactElement, initialEntries = ["/"]) {
  return render(
    <AuthProvider>
      <MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
    </AuthProvider>
  );
}

it("redirects unauthenticated users to login", () => {
  renderWithAuth(
    <Routes>
      <Route element={<RequireAuth />}>
        <Route path="/dashboard" element={<div>Dashboard</div>} />
      </Route>
      <Route path="/login" element={<div>Login Page</div>} />
    </Routes>,
    ["/dashboard"]
  );

  expect(screen.getByText(/login page/i)).toBeInTheDocument();
});

This pattern lets you:

  • Use MemoryRouter to simulate navigation
  • Assert that certain paths require authentication
  • Ensure redirects preserve location.state.from

5. Common pitfalls and how to avoid them

Some frequent issues in React Router apps:

  • Mixing imperative navigation with route-driven state: prefer redirect in actions and route loaders instead of navigate calls scattered through components.
  • Too many global contexts: keep feature-specific state scoped to features; only globalize what truly needs to be global (auth, theme, app config).
  • Deeply nested layouts without need: nesting can be powerful but overusing layout routes can make the tree hard to understand. Group logically.
  • Inconsistent URL conventions: decide early on patterns for plurals, kebab-case, and IDs (/dashboard/tasks/:taskId) and stick to them.
  • Forgetting error boundaries: always define errorElement for major branches (root, dashboard) so errors are user-friendly.

6. Performance considerations

React Router and Vite are already quite performant, but you can further improve:

  • Use lazy routes to shrink initial bundle size.
  • Memoize expensive components within large layouts (e.g. complex dashboards) when props are stable.
  • Avoid re-render storms by keeping global context value shapes stable (e.g. wrap updater functions in useCallback).
  • Use suspense boundaries around data-heavy sections.

Also consider production-oriented features:

  • Preloading critical routes when users hover or near-scroll to triggers
  • Splitting CSS alongside feature bundles via Vite plugins

7. Wrapping up the series

Across five parts, we've built a full-featured React Router + TypeScript app with Vite:

  • Solid foundation and basic routing
  • Nested layouts and dashboard sections
  • Route-based data loading and mutations
  • Authentication, protected routes, and roles
  • An architecture that can scale with your product

From here, natural next steps include:

  • Integrating a real backend (REST or GraphQL) instead of in-memory stores
  • Adding server-side rendering or pre-rendering if your stack supports it
  • Introducing UI component libraries or design systems
  • Hardening auth with real tokens and refresh flows

You now have a solid mental model and code structure to build production-grade routed applications in React.

Share this article:

Related Articles

FSI

Full Stack Insights

Software Engineer

Passionate about software development, architecture, and sharing knowledge with the community.