Vijit Ail Software Engineer at toothsi. I work with React and NodeJS to build customer-centric products. Reach out to me on LinkedIn or Instagram.

Complete guide to authentication with React Router v6

9 min read 2639

React Logo

React Router v6 is a popular and powerful routing library for React applications. It provides a declarative, component-based approach to routing and handles the common tasks of dealing with URL params, redirects, and loading data.

React Router provides one of the most intuitive APIs available and enables lazy loading and SEO-friendly server-side rendering. This latest version of React Router introduced many new concepts, like <Outlet /> and layout routes, but the documentation is still sparse.

This tutorial will demonstrate how to create protected routes and add authentication with React Router v6.

So, fire up your favorite text editor, and let’s dive in!

Jump ahead:

Getting started

Open up the terminal and create a new React project by running the following command:

> npx create-react-app ReactRouterAuthDemo
> cd ReactRouterAuthDemo

Next, install React Router as a dependency in the React app:

> npm install react-router-dom

Once the React Router dependency is installed, we’ll need to edit the src/index.js file.

Import BrowserRouter from react-router-dom and then wrap the <App /> component with <BrowserRouter />, like so:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";

import App from "./App";

const rootElement = document.getElementById("root");
const root = createRoot(rootElement);

root.render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>
);

Now, we’re all set up to use React Router components and hooks from anywhere in our app.

Let’s replace the boilerplate code from the App.js file with some routes.

Basic routing

React Router provides the <Routes /> and <Route /> components that enable us to render components based on their current location:

import { Routes, Route } from "react-router-dom";
import { LoginPage } from "./pages/Login";
import { HomePage } from "./pages/Home";
import "./styles.css";

export default function App() {
  return (
    <Routes>
      <Route path="/" element={<HomePage />} />
      <Route path="/login" element={<LoginPage />} />
    </Routes>
  );
}

Basic routing with <Route >

<Route /> provides the mapping between paths on the app and different React components. For example, to render the LoginPage component when someone navigates to /login, we just need to provide the <Route />, like so:

<Route path="/login" element={<LoginPage />} />

The <Route /> component can be thought of like an if statement; it will act upon a URL location with its element only if it matches the specified path.

Basic routing with <Routes />

The <Routes /> component is an alternative to the <Switch /> component from React Router v5.

To use <Routes />, we’ll first create Login.jsx and Home.jsx files inside the pages directory with the following content:

// Login.jsx
export const LoginPage = () => (
  <div>
    <h1>This is the Login Page</h1>
  </div>
);

// Home.jsx
export const HomePage = () => (
  <div>
    <h1>This is the Home Page</h1>
  </div>
);

Next, we’ll run this command to start the app:

> npm run start

On the browser, we see the HomePage component by default. If we go the /login route we’ll see the LoginPage component render on the screen.

Alternatively, we can use a plain JavaScript object to represent the routes in our app using the useRoutes hook. This is a functional approach for defining routes and works in the same manner as the combination of <Routes /> and <Route />components:

import { useRoutes } from "react-router-dom";
// ...

export default function App() {
  const routes = useRoutes([
    {
      path: "/",
      element: <HomePage />
    },
    {
      path: "/login",
      element: <LoginPage />
    }
  ]);
  return routes;
}

Now that the basic setup is completed, let’s look at how we can create protected routes so that unauthenticated users cannot access certain content in our application.



Creating protected routes

Before creating the protected route (also referred to as a private route), let’s create a custom hook that will handle the authenticated user’s state using the Context API and useContext hook:

import { createContext, useContext, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useLocalStorage } from "./useLocalStorage";
const AuthContext = createContext();

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useLocalStorage("user", null);
  const navigate = useNavigate();

  // call this function when you want to authenticate the user
  const login = async (data) => {
    setUser(data);
    navigate("/profile");
  };

  // call this function to sign out logged in user
  const logout = () => {
    setUser(null);
    navigate("/", { replace: true });
  };

  const value = useMemo(
    () => ({
      user,
      login,
      logout
    }),
    [user]
  );
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

export const useAuth = () => {
  return useContext(AuthContext);
};

With the useAuth hook, we are exposing the user’s state and a couple of methods for user login and logout. When the user logs out, we redirect them to the home page using React Router’s useNavigate hook.

To persist the user’s state even on page refresh, we‘ll use the useLocalStorage hook which will sync the state value in the browser’s local storage:

import { useState } from "react";

export const useLocalStorage = (keyName, defaultValue) => {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const value = window.localStorage.getItem(keyName);
      if (value) {
        return JSON.parse(value);
      } else {
        window.localStorage.setItem(keyName, JSON.stringify(defaultValue));
        return defaultValue;
      }
    } catch (err) {
      return defaultValue;
    }
  });
  const setValue = (newValue) => {
    try {
      window.localStorage.setItem(keyName, JSON.stringify(newValue));
    } catch (err) {}
    setStoredValue(newValue);
  };
  return [storedValue, setValue];
};

The <ProtectedRoute /> component will simply check the current user state from the useAuth hook and then redirect to the Home screen if the user is not authenticated:

import { Navigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";

export const ProtectedRoute = ({ children }) => {
  const { user } = useAuth();
  if (!user) {
    // user is not authenticated
    return <Navigate to="/" />;
  }
  return children;
};

To redirect the user, we use the <Navigate /> component. The <Navigate /> component changes the current location when it is rendered by the parent component. Internally, it uses the useNavigate hook.

In the App.js file, we can wrap the page component with the <ProtectedRoute /> component. In the example below, we are wrapping the <SettingsPage /> and <ProfilePage /> components with <ProtectedRoute />. Now, when unauthenticated users try to access /profile or /settings path they will be redirected to the home page:


import { Routes, Route } from "react-router-dom";
import { LoginPage } from "./pages/Login";
import { HomePage } from "./pages/Home";
import { SignUpPage } from "./pages/SignUp";
import { ProfilePage } from "./pages/Profile";
import { SettingsPage } from "./pages/Settings";
import { ProtectedRoute } from "./components/ProtectedRoute";


export default function App() {
  return (
    <Routes>
      <Route path="/" element={<HomePage />} />
      <Route path="/login" element={<LoginPage />} />
      <Route path="/register" element={<SignUpPage />} />
      <Route
        path="/profile"
        element={
          <ProtectedRoute>
            <ProfilePage />
          </ProtectedRoute>
        }
      />
      <Route
        path="/settings"
        element={
          <ProtectedRoute>
            <SettingsPage />
          </ProtectedRoute>
        }
      />
    </Routes>
  );
}

Well, the above approach works fine if there are a limited number of protected routes, but if there are multiple such routes we would have to wrap each one, which is tedious.

Instead, we can use the React Router v6 nested route feature to wrap all the protected routes in a single layout.

Using nested routes and <Outlet />

One of the most powerful features in React Router v6 is nested routes. This feature allows us to have a route that contains other child routes. The majority of our layouts are coupled to segments on the URL, and React Router supports this fully.

For example, we can add a parent <Route /> component to the <HomePage /> and <LoginPage /> routes, like so:

import { ProtectedLayout } from "./components/ProtectedLayout";
import { HomeLayout } from "./components/HomeLayout";
// ...

export default function App() {
  return (
    <Routes>
      <Route element={<HomeLayout />}>
        <Route path="/" element={<HomePage />} />
        <Route path="/login" element={<LoginPage />} />
      </Route>

      <Route path="/dashboard" element={<ProtectedLayout />}>
        <Route path="profile" element={<ProfilePage />} />
        <Route path="settings" element={<SettingsPage />} />
      </Route>
    </Routes>
  );
}

The parent <Route /> component can also have a path and is responsible for rendering the child <Route /> component on the screen.

When the user navigates to /dashboard/profile the router will render the <ProfilePage />. In order for this to occur, the parent route element must have an <Outlet /> component to render the child elements. The Outlet component enables nested UI to be visible when child routes are rendered.


More great articles from LogRocket:


The parent route element can also have additional common business logic and user interface. For example, in the <ProtectedLayout /> component we have included the private route logic and also a common navigation bar that will be visible when the child routes are rendered:

import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";

export const ProtectedLayout = () => {
  const { user } = useAuth();

  if (!user) {
    return <Navigate to="/" />;
  }

  return (
    <div>
      <nav>
        <Link to="/settings">Settings</Link>
        <Link to="/profile">Profile</Link>
      </nav>
      <Outlet />
    </div>
  )
};

Instead the <Outlet /> component, we can also opt to use the useOutlet hook which serves the same purpose:

import { Link, Navigate, useOutlet } from "react-router-dom";
// ...

export const ProtectedLayout = () => {
  const { user } = useAuth();
  const outlet = useOutlet();

  if (!user) {
    return <Navigate to="/" />;
  }

  return (
    <div>
      <nav>
        <Link to="/settings">Settings</Link>
        <Link to="/profile">Profile</Link>
      </nav>
      {outlet}
    </div>
  );
};

Similar to protected routes, we do not want authenticated users to access the /login path. Let’s handle that in the <HomeLayout /> component:

import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";

export const HomeLayout = () => {
  const { user } = useAuth();

  if (user) {
    return <Navigate to="/dashboard/profile" />;
  }

  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/login">Login</Link>
      </nav>
      <Outlet />
    </div>
  )
};

You can check out the complete code and demo in this CodeSandbox.

Using the React Router v6.4 data library APIs

In v6.4, the React Router package introduced new routers and data APIs. Going forward, all web apps should use the createBrowserRouter() function to enable data API access. The fastest way to update an existing app to the new API is by wrapping the Route components with the createRoutesFromElements() function:

export const router = createBrowserRouter(
  createRoutesFromElements(
    <>
      <Route element={<HomeLayout />}>
        <Route path="/" element={<HomePage />} />
        <Route path="/login" element={<LoginPage />} />
      </Route>

      <Route path="/dashboard" element={<ProtectedLayout />}>
        <Route path="profile" element={<ProfilePage />} />
        <Route path="settings" element={<SettingsPage />} />
      </Route>
    </>
  )
);

In the index.js file, instead of the <BrowserRouter /> component, use the <RouterProvider /> component and pass the exported router object from the App.js file. Also note that the AuthProvider will not work without BrowserRouter since it uses the useNavigate() function:

import { router } from "./App";
...
root.render(
  <StrictMode>
    <ThemeProvider theme={theme}>
      <RouterProvider router={router} />
    </ThemeProvider>
  </StrictMode>
);

To use the AuthProvider within the router context, we’ll need to create an <AuthLayout /> component that will wrap the outlet element with AuthProvider. This will enable all the child Routes to have access to auth context:

import { useLoaderData, useOutlet } from "react-router-dom";
import { AuthProvider } from "../hooks/useAuth";

export const AuthLayout = () => {
  const outlet = useOutlet();

  return (
    <AuthProvider>{outlet}</AuthProvider>
  );
};

Now, we can use the AuthLayout component as a root-level route, like so:

export const router = createBrowserRouter(
  createRoutesFromElements(
    <Route
      element={<AuthLayout />}
    >
      <Route element={<HomeLayout />}>
        ...
      </Route>

      <Route path="/dashboard" element={<ProtectedLayout />}>
        ...
      </Route>
    </Route>
  )
);

At this point, the app is ready to access the data APIs.

With React Router’s data API, we can abstract how the data is fetched. Usually, we’d load the data inside our component using the useEffect hook. Instead, we can use the Router’s loader() function to fetch the data before rendering the route element.

Consider a use case in which we need to get the logged-in user’s data when the application loads. Depending on whether the user is authenticated, we can redirect them to either the home page or the dashboard.

To simulate data fetching, we can use Promise with the setTimeout() method and get the user from localStorage:

const getUserData = () =>
  new Promise((resolve) =>
    setTimeout(() => {
      const user = window.localStorage.getItem("user");
      resolve(user);
    }, 3000)
  );

Using the loader prop on the Route component, we can pass the getUserData() Promise to the AuthLayout component with the help of the defer() utility function. The defer() function allows us to pass promises instead of resolved values before the Route component is rendered:

import {
  Route,
  createBrowserRouter,
  createRoutesFromElements,
  defer
} from "react-router-dom";

import { AuthLayout } from "./components/AuthLayout";
...
// ideally this would be an API call to server to get logged in user data
const getUserData = () =>
  new Promise((resolve) =>
    setTimeout(() => {
      const user = window.localStorage.getItem("user");
      resolve(user);
    }, 3000)
  );

export const router = createBrowserRouter(
  createRoutesFromElements(
    <Route
      element={<AuthLayout />}
      loader={() => defer({ userPromise: getUserData() })}
    >
      <Route element={<HomeLayout />}>
        ...
      </Route>

      <Route path="/dashboard" element={<ProtectedLayout />}>
        ...
      </Route>
    </Route>
  )
);

In the AuthLayout component, you can access the userPromise using the useLoaderData hook.

The Await component can render deferred values with an inbuilt error-handling mechanism. The Await component should be wrapped in React Suspense to enable a fallback UI. In this case, we’re rendering a linear progress bar until the userPromise is resolved.

We can pass a component to the errorElement prop to render an error UI state if the Promise gets rejected.

Finally, we can pass the user data as an initial value to the AuthProvider:

import { Suspense } from "react";
import { useLoaderData, useOutlet, Await } from "react-router-dom";
import LinearProgress from "@mui/material/LinearProgress";
import Alert from "@mui/material/Alert";
import { AuthProvider } from "../hooks/useAuth";

export const AuthLayout = () => {
  const outlet = useOutlet();

  const { userPromise } = useLoaderData();

  return (
    <Suspense fallback={<LinearProgress />}>
      <Await
        resolve={userPromise}
        errorElement={<Alert severity="error">Something went wrong!</Alert>}
        children={(user) => (
          <AuthProvider userData={user}>{outlet}</AuthProvider>
        )}
      />
    </Suspense>
  );
};

To verify the error condition, you can reject the Promise as shown below:

// for error
const getUserData = () =>
  new Promise((resolve, reject) =>
    setTimeout(() => {
      reject("Error");
    }, 3000)
  );

You can check out the complete code with data library integration in this CodeSandbox.

Conclusion

It’s worth investing some time to better understand how React Router v6 works, particularly for the common use case of user authentication.

React Router v6 is a huge improvement over previous versions. It’s fast, stable, and reliable. In addition to being easier to work with, it has many new features, like <Outlet /> and an improved <Route /> component, that have greatly simplified routing in React apps.

With new routers and data APIs available in v6.4, you can easily handle optimistic UI, pending, and error states. You can abstract and load data outside the component while displaying a fallback UI until the data is ready.

I hope you found this guide helpful and now have a better understanding of how to handle user authentication with React Router v6.

Cut through the noise of traditional React error reporting with LogRocket

LogRocket is a React analytics solution that shields you from the hundreds of false-positive errors alerts to just a few truly important items. LogRocket tells you the most impactful bugs and UX issues actually impacting users in your React applications. LogRocket automatically aggregates client side errors, React error boundaries, Redux state, slow component load times, JS exceptions, frontend performance metrics, and user interactions. Then LogRocket uses machine learning to notify you of the most impactful problems affecting the most users and provides the context you need to fix it.

Focus on the React bugs that matter — .

Vijit Ail Software Engineer at toothsi. I work with React and NodeJS to build customer-centric products. Reach out to me on LinkedIn or Instagram.

6 Replies to “Complete guide to authentication with React Router v6”

  1. In the example where login path is removed after login, the home page is no longer accessible. To reproduce, after login, try to go to home page. It will always redirect to /dashboard/profile. I think this shouldnt be the ideal scenario.

  2. i cant use this scenario because im using a theme that used react router dom v6 and useRoutes i dont have routes and route jsx and i cant replace them

  3. I’m getting the following error: React Hook React.useMemo has missing dependencies: ‘login’ and ‘logout’ Either include them or remove them from the dependency array. Do you have a solution for this error?

  4. Don’t Try this tutorial, it is not authenticating Token, Only it generate a token and store it in local storage.. this is not the technically correct

    1. Hey Sooraj, the actual authentication of the user is not in scope of the article. This article only demonstrates how you’d handle authentication and private routes on the frontend using React Router.

Leave a Reply