Endurative

Scalable Routing in React.js with React Router Dom v6: The Ultimate Performance Guide [2025]

As your React application grows, managing routes without structure quickly leads to bottlenecks like slow page loads, longer build times, and hard-to-debug access errors. A scalable routing system ensures your app remains fast, modular, and maintainable.


Introduction to Scalable Routing in React

Why Routing Scalability Matters

As applications grow, so do the number of routes. Without a structured approach, you'll encounter:

  1. Longer build times
  2. Slower initial load (bad UX)
  3. Hard-to-debug permission errors
  4. Duplicate or forgotten paths

Challenges of Traditional Routing Methods

  1. Component-heavy route files become unreadable.
  2. All routes load upfront, increasing bundle size.
  3. No standard way to manage permissions per route.

Core Principles of Scalable Routing

A scalable routing system in React is built on three pillars:

1: Lazy Loading (Code Splitting)

Only load components when needed. Using React.lazy and Suspense, the app renders fast and fetches heavy components only when users visit them.

2: Private Routes (Access Control)

Define which roles can access what pages using a reusable function. Prevent unauthorized users from accessing admin or premium content.

3: Centralized Routing (Maintainability)

Keep all route definitions in a single file. Easy to scan, update, and extend.

Project Structure Overview

src/
├── routes/
│ ├── routes.tsx
│ ├── PrivateRoute.tsx
│ ├── PublicRoute.tsx
│ ├── RouterComponent.tsx
│ └── CustomLoadable.tsx
├── security/
│ └── permission.ts

Benefits:

  1. Clear separation of concerns
  2. Reusable and modular
  3. Scalable for teams and large codebases

Defining Routes in routes.tsx

//Public Routes
const publicRoutes = [
{
path: "/auth/signin",
loader: () => import("src/view/auth/SigninPage"),
},
];

// Private Routes with Permissions
const privateRoutes = [
{
path: "/user",
loader: () => import("src/view/user/list/UserPage"),
permissionRequired: permissions.userRead,
},
];

// Fallback Routes
const simpleRoutes = [
{
path: "**",
loader: () => import("src/view/shared/errors/Error404Page"),
},
];

You define routes as arrays with path, loader, and optionally, permissionRequired. Using import() ensures components are lazy-loaded.

Permissions Management (permissions.ts)

const roles = {
admin: "admin",
user: "user",
guest: "guest",
};

class Permission {
static get values() {
return {
firstpage: {
id: "firstpage",
allowedRoles: [roles.admin, roles.user],
},
secondpage: {
id: "secondpage",
allowedRoles: [roles.admin],
},
thirdpage: {
id: "thirdpage",
allowedRoles: [roles.user],
},
fourthpage: {
id: "fourthpage",
allowedRoles: [roles.guest],
},
sixthpage: {
id: "sixthpage",
allowedRoles: null,
},
};
}

static checkPermission(permissionId, userRole) {
const permission = this.values[permissionId];

if (!permission) {
return false; // Unknown permission ID means no access
}

return permission.allowedRoles.includes(userRole);
}
}

export default Permission;

Each permission maps to roles allowed to access it. This logic is centralized and reused in route guards.

Implementing PrivateRoute.tsx


export default function PrivateRoute({ permissionRequired, children }) {
const userRole = useSelector((state) => state.auth.currentUser.role);
const hasAccess = PermissionChecker.check(userRole, permissionRequired);
return hasAccess ? children : <Navigate to="/403" />;
}

If a user lacks required permission, they're redirected to the 403 Unauthorized page.

Implementing PublicRoute.tsx

const PublicRoute = ({ children }) => {
const isAuthenticated = useSelector(
(state) => state.auth.currentUser.isAuthenticated
);
return isAuthenticated ? <Navigate to="/" /> : children;
};

If the user is already logged in, they can't revisit public routes like /auth/signin.

Creating CustomLoadable.tsx

This component lazy-loads all your pages:

export default function CustomLoadable({ loader }) {
const LazyComponent = lazy(loader);
return (
<Suspense fallback={<Loader isCenter={true} />}>
<LazyComponent />
</Suspense>
);
}

Wrap your dynamic imports with React.Suspense and show a loading spinner while loading.

Centralized Routing with RouterComponent.tsx

<BrowserRouter>
<Routes>
{routes.publicRoutes.map(({ path, loader }) => (
<Route
path={path}
element={
<PublicRoute>
<CustomLoadable loader={loader} />
</PublicRoute>
}
/>
))}
{routes.privateRoutes.map(({ path, loader, permissionRequired }) => (
<Route
path={path}
element={
<PrivateRoute permissionRequired={permissionRequired}>
<CustomLoadable loader={loader} />
</PrivateRoute>
}
/>
))}
{routes.simpleRoutes.map(({ path, loader }) => (
<Route path={path} element={<CustomLoadable loader={loader} />} />
))}
</Routes>
</BrowserRouter>;

Each route type uses a guard component (PublicRoute or PrivateRoute) and lazy loader for clean, performant handling.


Complete Flow: How Everything Ties Together

  1. User navigates to a route.
  2. Route is matched in RouterComponent.tsx.
  3. Component is lazily loaded via CustomLoadable.tsx.
  4. If private:
  5. Permission is checked
  6. Redirect if denied
  7. Render page.
This routing setup boosts performance, secures access, and simplifies maintenance—perfect for large teams and role-based apps.