DEV Community

Kirby Aguilar
Kirby Aguilar

Posted on

Setting up private routes with react router v6

When I was tasked to lead my previous company's Rails+React app development, I had zero real React experience. One month into the development process, I had to put most of our routes behind a wall of auth.

I encountered a few snags:

  • None of the solutions I found online felt simple enough, nothing I could do in 5 minutes. At my level at the time, it seemed like every guide needed complex hooks or React knowledge that I simply didn't have yet.
  • Our project was using the newer Router Provider syntax. Most of the guides I found were using the older JSX syntax for routes
  • Our project's auth was being done using devise and devise-jwt. Ideally, I would have wanted a quick and dirty implementation of react-router private routes that throws the responsibility of actual auth all to the backend, but I couldn't find that at the time.

This problem has been long solved for our project, but I wanted to share the solution we landed on.

Skipping to the end, for impatient readers who know what they're doing

If you don't care for the details, you can skip to the end via the GitHub repository. You'll want to be looking at router.tsx, hooks/useAuth.ts and components/PrivateRoute.tsx.

The goal

If you do care about the details, then you'll want to know that we want to achieve the following:

  1. "Is this route private?" needs to be something we can configure per route
  2. "Is this route private?" also needs to be something we can configure per layout. i.e. if my route's parent is private, I won't have to set the child route to private.
  3. Authentication needs to happen within the app's backend API.

Project Setup

This section covers how to create this project through npm and Vite.

$ npm create vite@latest react-router-protected-routes -- --template react-ts
$ cd react-router-protected-routes && npm install
$ npm install react-router-dom

# remove boilerplate files that we do not need 
$ cd src && rm -rf assets/ App.tsx App.css index.css && cd ..
Enter fullscreen mode Exit fullscreen mode

Let's make a couple of pages:

// src/pages/Login.tsx
export default function Login() {
  return (
    <>
      <h1>Login page</h1>
    </>
  );
}

// src/pages/Protected.tsx
export default function Protected() {
  return (
    <h1>This should only be visible if you are logged in</h1>
  );
}
Enter fullscreen mode Exit fullscreen mode

1. Creating our router and setting up main.tsx

We elect to use createBrowserRouter as per react-router's official recommendation in their docs.

// src/router.tsx
import { createBrowserRouter } from "react-router-dom";

import Login from "./pages/Login";
import Protected from "./pages/Protected";

/**
 * Notice how "main" routes and auth routes (like login or signup) are separated below.
 * This mimics how a real project may be set up: these two sets of routes would likely
 * have a different layout file, main routes would be protected while auth routes would not, etc
 */
const router = createBrowserRouter([
  // main routes
  {
    path: '/',
    element: <Protected />
  },

  // auth routes
  {
    children: [
      { path: 'login', element: <Login /> },
    ],
  },
])
export default router;
Enter fullscreen mode Exit fullscreen mode

We then edit main.tsx such that it makes use of the router we just created:

// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'

import router from './router'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>,
)
Enter fullscreen mode Exit fullscreen mode

At this point, you can $ npm run dev to ensure that navigation is working. We haven't implemented private routes yet, so you should be able to see both / and /login.

2. The useAuth custom hook

Before we can create a PrivateRoute component, we need to write the custom hook that we'll use within the component to see a user's authentication status.

As shown below, if you're using this within your app then you'd be calling the backend API within the try block to check there whether the user is authenticated or not.

// src/hooks/useAuth.ts
import { useEffect, useState } from "react";

export const useAuth = () => {
  const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);

  useEffect(() => {
    const fetchData = async () => {      
      try {
        // Normally here we'd send a request to a protected route of the backend API of the app.
        // whether or not the user is authenticated depends on the result of the request
        // await axios.get("/api/arbitrary");

        // For our purposes, we'll simulate a 250ms delay in place of the API call
        await new Promise(resolve => setTimeout(resolve, 250));

        // Set this to true for testing
        const simulateFailure = false;
        if (simulateFailure) throw new Error("Failed to authenticate");

        setIsAuthenticated(true)
      } catch (error) {
        console.error("Error fetching data:", error);
        setIsAuthenticated(false);
      }
    };

    fetchData();
  }, []);

  return isAuthenticated;
};
Enter fullscreen mode Exit fullscreen mode

Some readers might have noticed something strange. See the line below:

const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
Enter fullscreen mode Exit fullscreen mode

Why is it that isAuthenticated is allowed to be null?

This is because there are three possible states in determining a user's authentication status:

  • The user is authenticated.
  • The user is not authenticated.
  • We don't know yet--likely because we're still waiting for a response from the backend. This "loading" state is what we represent with null, as you'll see from our PrivateRoute component.

3. The PrivateRoute component

We pass in a component to the PrivateRoute. While checking for the authentication status, we return a loading state (you could use a spinner or something similar here instead).

Afterwards, we return the passed in component if authentication was successful, and a redirect to the non-private route /login if not.

// src/components/PrivateRoute.tsx
import { Navigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";

type PrivateRouteProps = {
  component: React.FC;
}

const PrivateRoute: React.FC<PrivateRouteProps> = ({ component: Component }) => {
  const isAuthenticated = useAuth();
  if (isAuthenticated === null) return <div>Loading...</div>;
  return isAuthenticated ? <Component /> : <Navigate to="/login" />;
};

export default PrivateRoute;
Enter fullscreen mode Exit fullscreen mode

To make use of private routes, simply pass it in as a route's element in router.tsx like so:

// src/router.tsx
import PrivateRoute from "./components/PrivateRoute";
// ...
const router = createBrowserRouter([
  // main routes
  {
    path: '/',
    element: <PrivateRoute component={Protected} />
  },
// and so on
Enter fullscreen mode Exit fullscreen mode

And that's it! You should now be able to set routes as private. If you want to set a whole family of routes as private, simply provide PrivateRoute with a layout file that makes use of an <Outlet> and the children should become private as well.

An important caveat

This all happened to be a quick and dirty solution that we were fine using a bit long-term at my previous company, but it's not perfect. You may have noticed that useAuth calls the backend API to check for authentication every single time a component is rendered.

While it's outside the scope of this guide, you can counteract this by caching, using react's context API, or through a state management library.

References

While I wasn't able to find a perfect reference for myself before (hence this article), I was still able to find other helpful sources that were instrumental in figuring out what to do. If this tutorial wasn't the right one for you, maybe I can at least lead you to the right place:

Top comments (0)