DEV Community

Cover image for Stop copy-pasting your React route protection. Here's a better way.
Askold Astakhov
Askold Astakhov

Posted on • Edited on • Originally published at aastakhov.com

Stop copy-pasting your React route protection. Here's a better way.

On every project you need to protect routes. Some are for authenticated users only, some for admins, some for guests only. You write a PrivateRoute. Then a role check hook. Then a HasAccess component. You move to the next project — and write it all again.

The logic is always the same. The implementation is always different.

react-protected standardizes this. You describe what each route requires. The library checks access and redirects.

Three packages

@react-protected/core — framework-agnostic access-control logic. No dependency on React or any router.

@react-protected/react — React context (AccessProvider), hooks (useHasAccess), and HasAccess component.

@react-protected/react-router — adapter for React Router. Includes everything from @react-protected/react — no need to install both.

Installation

For React Router projects:

npm install @react-protected/react-router
Enter fullscreen mode Exit fullscreen mode

For React projects without React Router:

npm install @react-protected/core @react-protected/react
Enter fullscreen mode Exit fullscreen mode

Config-based routing (recommended)

// router.ts
import { createAccessRouter } from '@react-protected/react-router'
import { useAuthStore } from './entities/auth'

export const router = createAccessRouter(
  [
    { path: '/', element: <HomePage /> },
    { path: '/login', element: <LoginPage />, access: 'guest-only' },
    { path: '/dashboard', element: <DashboardPage />, access: 'authenticated' },
    { path: '/admin', element: <AdminPage />, access: 'authenticated', roles: ['admin'] },
    {
      path: '/contracts',
      element: <ContractsPage />,
      access: 'authenticated',
      permissions: ['contracts:read'],
    },
    { path: '/403', element: <Page403 /> },
  ],
  {
    getUser: () => useAuthStore.getState().user,
    hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
    hasPermission: (user, permissions) =>
      permissions.every((p) => user.permissions.includes(p)),
    loginPath: '/login',
    forbiddenPath: '/403',
    defaultPath: '/dashboard',
    callbackUrlParam: 'next',
  }
)

// App.tsx
import { RouterProvider } from 'react-router-dom'
export const App = () => <RouterProvider router={router} />
Enter fullscreen mode Exit fullscreen mode

JSX routes

import { Route, Routes } from 'react-router-dom'
import { AccessProvider, AccessRoute } from '@react-protected/react-router'

const App = () => (
  <AccessProvider
    getUser={() => useAuthStore.getState().user}
    hasRole={(user, roles) => roles.some((role) => user.roles.includes(role))}
    loginPath="/login"
    forbiddenPath="/403"
    defaultPath="/dashboard"
    callbackUrlParam="next"
  >
    <Routes>
      <Route path="/" element={<HomePage />} />
      <Route
        path="/login"
        element={
          <AccessRoute access="guest-only">
            <LoginPage />
          </AccessRoute>
        }
      />
      <Route
        path="/dashboard"
        element={
          <AccessRoute access="authenticated">
            <DashboardPage />
          </AccessRoute>
        }
      />
    </Routes>
  </AccessProvider>
)
Enter fullscreen mode Exit fullscreen mode

Roles, permissions, or both

RBAC — pass roles to the route. You define the check logic yourself: OR, AND, hierarchy — whatever your domain requires.

ABAC — pass permissions. Works the same way.

Both at once — the check passes only when both conditions are met.

Guarding UI elements

Route protection is not enough. HasAccess and useHasAccess let you hide buttons, fields, or any UI element based on role or permission — no route change required.

import { HasAccess, useHasAccess } from '@react-protected/react-router'

// Component
const Toolbar = () => (
  <nav>
    <HasAccess roles={['admin']}>
      <button>Delete user</button>
    </HasAccess>
  </nav>
)

// Hook
const ExportButton = () => {
  const canExport = useHasAccess({ permissions: ['reports:export'] })
  return canExport ? <button>Export</button> : null
}
Enter fullscreen mode Exit fullscreen mode

Callback URL

When callbackUrlParam is set, unauthenticated users are redirected with the current path preserved:

/dashboard?tab=overview → /login?next=%2Fdashboard%3Ftab%3Doverview
Enter fullscreen mode Exit fullscreen mode

Handle the return in your login page:

const [params] = useSearchParams()
navigate(params.get('next') ?? '/dashboard', { replace: true })
Enter fullscreen mode Exit fullscreen mode

If you want to suppress the callback URL after an explicit logout:

<AccessProvider
  callbackUrlParam="next"
  shouldAddCallbackUrl={() => !authStore.getState().loggedOut}
  ...
>
Enter fullscreen mode Exit fullscreen mode

Not using React Router?

Use @react-protected/core directly with any router. Here's TanStack Router:

import { createGuard } from '@react-protected/core'

const guard = createGuard({
  getUser: () => useAuthStore.getState().user,
  hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
})

const dashboardRoute = createRoute({
  path: '/dashboard',
  beforeLoad: ({ location }) => {
    const result = guard.check({ access: 'authenticated' })
    if (!result.allowed) {
      throw redirect({
        to: result.reason === 'unauthenticated' ? '/login' : '/403',
        search: { next: location.pathname },
      })
    }
  },
  component: DashboardPage,
})
Enter fullscreen mode Exit fullscreen mode

👉 github.com/astakhovaskold/react-protected

Issues and stars are welcome.

Top comments (0)