Kirby Aguilar
Posted on September 26, 2024
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
anddevise-jwt
. Ideally, I would have wanted a quick and dirty implementation ofreact-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:
- "Is this route private?" needs to be something we can configure per route
- "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.
- 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 ..
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>
);
}
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;
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>,
)
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;
};
Some readers might have noticed something strange. See the line below:
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
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 ourPrivateRoute
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;
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
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:
Posted on September 26, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.