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.
In the first four parts of this series, we built a progressively more realistic React Router + TypeScript application on top of a Vite starter:
- Part 1 – Fundamentals and basic pages
- Part 2 – Nested routes and dashboard layout
- Part 3 – Data loading and CRUD flows
- 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
MemoryRouterto 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
redirectin actions and route loaders instead ofnavigatecalls 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
errorElementfor 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.
Related Articles
Routing in React with React Router and TypeScript – Part 4: Auth and Protected Routes
Add authentication and protected routes to your React Router + TypeScript app. We’ll build a simple auth flow with login, logout, and role-based access inside the Vite-powered dashboard.
Routing in React with React Router and TypeScript – Part 3: Data Loading and CRUD Flows
Learn how to use React Router’s data APIs (loaders and actions) with TypeScript in a Vite React app. We’ll build a simple CRUD flow inside the dashboard section using route-based data fetching and mutations.
Full Stack Insights
Software Engineer
Passionate about software development, architecture, and sharing knowledge.
Quick Links
Full Stack Insights
Software Engineer
Passionate about software development, architecture, and sharing knowledge with the community.