Skip to content

React Router, Vite and JWT Authentication

This is meant to act as a summary and reference for implementing React Router with hashed authentication in your Vite project. The project is made as a Single User Login using an .env file as basis to store credentials. It can be modified for multiple use cases.

Well according to React Router themselves.

React Router is a multi-strategy router for React bridging the gap from React 18 to React 19. You can use it maximally as a React framework or as minimally as you want. Source

The site goes on to explain there are three modes available. For the purposes of this exercise we are primarily focused on declarative components.

I recommend reading the full documentation on React Router should additional requirements pop up.

As part of this exercise, the most important lesson to remember is the delineation between client and server. As we are using stored credentials, any interaction with those credentials needs to:

  1. Ensure that the communication of those credentials is encrypted.
  2. Those credentials are not validated or exposed at a client level.
  3. Restrict access to the sever when it comes to matters related to credentials is critical.

This article assumes you are familiar with Node, React, and have deployed a Vite project as the basis for your web application. The code provided is written in TypeScript.

Below are the main files we will be working with in your pre installed Vite folder structure.

/root-project
├── /server
│ ├── server.js
│ ├── .env
├── /src
│ ├── App.tsx
│ ├── /utils
│ │ └── ProtectedRoutes.tsx
│ ├── /components
│ │ ├── Dashboard.tsx
│ │ ├── Login.tsx
│ │ └── Home.tsx

The following dependencies are used in this project across both client and server (break down to follow):

"vite": "^6.2.2",
"typescript": "~5.7.2",
"react-dom": "^19.0.0",
"react-router-dom": "^7.3.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jose": "^6.0.10"

These modules should be targeting your Vite project folder, ensure relevant package.json folders are updated with the correct dependencies.

The below NPM command’s can be utilised to install relevant dependencies client side:

Vite To create a new Vite project with React and TypeScript template:

Terminal window
npm create vite@latest <YOUR_PROJECT_NAME> -- --template react-ts

react-dom To install react-dom:

Terminal window
npm install react-dom

react-router-dom To install react-router-dom (for routing):

Terminal window
npm install react-router-dom

These modules should be targeting your server folder, to have a separate set of modules within your Vite project structure it is recommended to run the npm init -y command in your server’s root directory prior to executing additional install commands.

The below NPM command’s can be utilised to install relevant dependencies server side:

dotenv To install dotenv (hold our server side secrets):

Terminal window
npm install dotenv

cors To install cors (allows for cross origin traffic filterting on our server):

Terminal window
npm install cors

express To install express (to host our server):

Terminal window
npm install express

jose To install jose (JWT utility):

Terminal window
npm install jose
  1. Client Dependencies: Make sure you’re installing the relevant modules (vite, react-dom, and react-router-dom) in the /src folder of your Vite project.
  2. Server Dependencies: Use npm init -y to initialize a package.json in your /server folder. Then, install the necessary server-side modules (dotenv, cors, express, and jose) there.
  3. Environment Configuration: Ensure that your .env file is placed in the /server folder for storing sensitive information like SECRET_KEY and other configuration details.

The user management side of things will be extremely simple, whilst a more complex system can be applied to manage multiple users authenticating. This article will focus on a single-user login using an .env file. The methodology applied is still very relevant as we are managing an Authenticated user with local storage authentication and routing accordingly. The same logic can be applied to a validated SQL Row as an example.

If you are unfamiliar with .env files and the significant role they may play in your application, I would recommend reading this article alongside doing some additional research.

Below is an example .env file to examine:

ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
SECRET_KEY=changeme

We have three variables being stored: a username, password and secret which our application will interface with to verify a successful login.

Let’s dive into our server.js code:

The comment’s should provide a high level insight.

// We are importing the node installed components as a first step.
const express = require('express');
const { SignJWT, jwtVerify } = require('jose');
const cors = require('cors')
require('dotenv').config();
/* Setting our parameters for the server including variables assigned
to run our application.*/
const app = express();
/* Whilst this step is optional, it is highly recommended to set cross-origin
rules to control which origins can access your API and prevent unauthorised
access from untrusted networks.
Please note the origin and configure it accordingly for production builds.
It’s recommended to use an environment variable to store your project’s root
domain, ensuring consistency across different environments and potential
variables. */
const corsOptions = {
origin: 'http://localhost/',
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization'],
};
// Advising the application to use CORS and express
app.use(cors(corsOptions));
app.use(express.json());
// Setting our variables from our .env file
const SECRET_KEY = process.env.SECRET_KEY;
const ADMIN_USERNAME = process.env.ADMIN_USERNAME;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
// This is our login API request (more details below)
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
const payload = { username };
try {
const token = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(new TextEncoder().encode(SECRET_KEY));
res.json({ token });
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Internal server error.' });
}
} else {
res.status(401).json({ message: 'Invalid username or password.' });
}
});
/*This is our JWT token verification middleware function (it checks the
validity of the token for accessing protected routes).*/
const verifyToken = async (req, res, next) => {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(403).json({ message: 'No token provided.' });
}
try {
await jwtVerify(token, new TextEncoder().encode(SECRET_KEY));
next();
} catch (err) {
console.error('Token verification failed:', err);
res.status(401).json({ message: 'Invalid or expired token.' });
}
};
// These are our protected routes
app.get('/api/protected', verifyToken, (req, res) => {
res.json({ message: 'This is a protected route' });
});
/* Finally, we initialise our app by listening on port 3000 (Port can
change or be removed for cloud instances like Heroku)*/
app.listen(3000, () => {
console.log('Server running on port 3000');
});
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
const payload = { username };
try {
const token = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(new TextEncoder().encode(SECRET_KEY));
res.json({ token });
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Internal server error.' });
}
} else {
res.status(401).json({ message: 'Invalid username or password.' });
}
});

Below is a break down of the above code:

  1. Defining the Login Route:
  • A POST request is set up at the /api/login endpoint to handle login requests.
  1. Request Body Validation:
  • The incoming request body is de structured to extract username and password.
  • These are compared with the ADMIN_USERNAME and ADMIN_PASSWORD environment variables, effectively checking credentials for a single user login.
  1. JWT Token Creation:
  • If the credentials match, a JWT (JSON Web Token) is created using the username as the payload.
  • The token is signed using the HS256 algorithm, with the SECRET_KEY encoded to ensure secure hashing.
  1. Token Metadata:
  • The token is configured with:
    • setIssuedAt(): Marks the time the token was issued.
    • setExpirationTime(‘1h’): Sets the token to expire after one hour.
    • setProtectedHeader({ alg: ‘HS256’ }): Specifies the signing algorithm used.
  1. Returning the Token:
    • The generated JWT token is returned to the client in the response as a token object.
    • This token is then used for authenticating subsequent requests via middleware to access protected routes.
const verifyToken = async (req, res, next) => {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(403).json({ message: 'No token provided.' });
}
try {
await jwtVerify(token, new TextEncoder().encode(SECRET_KEY));
next();
} catch (err) {
console.error('Token verification failed:', err);
res.status(401).json({ message: 'Invalid or expired token.' });
}
};

Below is a break down of the above code:

  1. Extracting the Token from the Authorization Header:
  • The middle ware first checks the Authorization header and splits it by spaces. Typically, the header will have the format Bearer <token>, so we extract the token portion after the space.
  1. Checking if the Token Exists:
  • We ensure that the token exists. If it’s missing or null, the middle ware responds with a 403 Forbidden status and an error message saying “No token provided.”
  1. Verifying the Token’s Validity:
  • The token is then verified using jwtVerify function, which checks the token against the SECRET_KEY. If the token is valid and the signature matches, the middleware allows the request to proceed by calling next().
  1. Error Handling:
  • If there’s any issue with the token (e.g., invalid, expired, or tampered with), an error is thrown and caught returning a status of 401 Unauthorized denying access to the requested resource.

At this point, we have a server with endpoints capable of handling authentication via JWT tokens. The client can request a token by providing valid credentials, and the server will issue a signed JWT. Subsequent requests to protected routes will require the token for access, ensuring that only authenticated users can reach sensitive endpoints.

The App.tsx file serves as a “gateway” to the different routes available in your application. Traditionally, this file contains the core logic for how a user can navigate to various features of your app. For the purposes of this article, we will implement exactly that. As suggested by the file structure, our three main landing pages will be the Home Page, Login Page, and Dashboard Page.

Let’s have a look at the file and break it down.

App.tsx
import './App.css'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './components/Home'
import Login from './components/Login';
import Dashboard from './components/Dashboard';
import ProtectedRoutes from './utils/ProtectedRoutes';
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home/>}/>
<Route element={<ProtectedRoutes/>}>
<Route path="/admin/dashboard" element={<Dashboard/>} />
</Route>
<Route path="/admin" element={<Login />} />
</Routes>
</Router>
)
}
export default App
import './App.css'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './components/Home'
import Login from './components/Login';
import Dashboard from './components/Dashboard';
import ProtectedRoutes from './utils/ProtectedRoutes';

Here we are doing three main imports:

  1. Global CSS: The App.css file for styling, which is standard in Vite’s React TypeScript setup.
  2. React Router Components: We import Router, Routes, and Route from react-router-dom to enable routing functionality in the application.
  3. Custom Components: We import the Home, Login, and Dashboard components from the components folder to render the respective pages.
  4. ProtectedRoutes: This is a custom utility component that handles protecting certain routes, ensuring only authenticated users can access them.

The <Router>is the parent element for our <Routes>, aligning location context (Determining the URL Path). It is a declarative element that can either be a StaticRouter (used for server-side rendering) or a BrowserRouter (used for client-side rendering). In this case, we have declared our router to be a BrowserRouter, as Vite is optimised for client-side rendering.

The <Routes> component serves as the parent container that holds all of our <Route> logic. It allows the <Router> to match the current URL and render the appropriate components based on the matching route.

All of our customisation will be done at this level. The <Route> is pointing our router to its final destination through the path property, returning the correct component based on the element property.

function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home/>}/>
<Route element={<ProtectedRoutes/>}>
<Route path="/admin/dashboard" element={<Dashboard/>} />
</Route>
<Route path="/admin" element={<Login />} />
</Routes>
</Router>
)
}
export default App

Here’s a breakdown of the App.tsx JSX return:

  • Router Component: The <Router> wraps all of the routes to provide routing functionality based on the URL.
  • Routes Component: Inside the <Router>, the <Routes> component holds all the <Route> definitions.

Paths and Components:

  • Home Page: The first route (<Route path="/" element={<Home />} />) corresponds to the root URL (/). When a user visits the root path, the Home component is rendered.
  • Login Page: The second route (<Route path="/admin" element={<Login />} />) is for the login page. It matches the /admin path and renders the Login component.
  • Dashboard Page (Protected): The third route (<Route path="/admin/dashboard" element={<Dashboard />} />) is nested inside a ProtectedRoutes wrapper. This ensures that only authenticated users can access the dashboard page at /admin/dashboard.

ProtectedRoutes Component: The ProtectedRoutes component is used to wrap the routes that require authentication. This ensures that only authenticated users can access the /admin/dashboard route. If the user is not authenticated, they will be redirected to the login page or shown an appropriate error message.

The App.tsx file is responsible for setting up routing in our application. It uses React Router’s BrowserRouter, Routes, and Route components to render different views based on the current URL:

  • Home: Accessible via the root URL (/).
  • Login: Available at /admin.
  • Dashboard: A protected route at /admin/dashboard that requires authentication.

The ProtectedRoutes.tsx component acts as a wrapper for routes that require authentication. It checks if the user has a valid authorization token before allowing access to the child routes inside it. If the token is not valid or missing, the user is redirected to the login page.

Let’s have a look at the file and break it down:

import { Outlet, useNavigate } from "react-router-dom";
import { useEffect } from "react";
const ProtectedRoutes = () => {
const token = localStorage.getItem("authToken");
const navigate = useNavigate();
useEffect(() => {
const verifyToken = async () => {
if (token) {
try {
const response = await fetch('http://localhost:3000/api/protected', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
// Send the token in the Authorization header
}
});
if (!response.ok) {
localStorage.removeItem("authToken");
navigate('/admin', { replace: true });
}
} catch (error) {
console.error("Token verification failed:", error);
localStorage.removeItem("authToken");
navigate('/admin', { replace: true });
}
} else {
navigate('/admin', { replace: true });
}
};
verifyToken();
}, [token, navigate]);
return token ? <Outlet /> : null;
};
export default ProtectedRoutes;
import { Outlet, useNavigate } from "react-router-dom";

We are not introduced to two new imports from react-router-dom:

  1. useNavigate: The useNavigate function sends a navigation request when triggered, as of writing this article this is the preferred method of navigation suggested by React Router.
  2. Outlet: The Outlet element returns nested Routes of the parent component.
const ProtectedRoutes = () => {
const token = localStorage.getItem("authToken");
const navigate = useNavigate();
useEffect(() => {
const verifyToken = async () => {
if (token) {
try {
const response = await fetch(
'http://localhost:3000/api/protected',
{
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
}
});
if (!response.ok) {
localStorage.removeItem("authToken");
navigate('/admin', { replace: true });
}
} catch (error) {
console.error("Token verification failed:", error);
localStorage.removeItem("authToken");
navigate('/admin', { replace: true });
}
} else {
navigate('/admin', { replace: true });
}
};
verifyToken();
}, [token, navigate]);
return token ? <Outlet /> : null;
};

Below is a break down of the above code:

  1. Setting Token and Navigation Variables:
  • The function will be relying on a locally stored token from localStorage to determine the users authorisation status.
  • The function needs a navigate variable to trigger useNavigate
  1. Token Validation:
  • The useEffect hook is responsible for the validation based on changes to token or navigate
  • We ensure that the token exists prior to validation attempts.
  • The function then try’s the ap/protected endpoint where we have written our token verification function server side.
  • Should the signatures match and the token is deemed valid the router allows for children via the <Outlet/> to be rendered
  1. Error Handling:
  • If there’s any issue with the token (e.g., invalid, expired, or tampered with), an error is thrown any instance of the local token is deleted and the user is returned to the unprotected /admin page.

The Dashboard and Home components are quite open-ended. You can include whatever JSX you are comfortable with. The only suggestion I would make is including a <button> component (or something similar) in the Dashboard to allow the user to log out.

import { useState } from "react"
import { useNavigate } from 'react-router-dom';
function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const nav = useNavigate();
const checkLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch('http://localhost:3000/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const { token } = await response.json();
localStorage.setItem('authToken', token);
nav('/admin/dashboard', { replace: true });
} else {
const { message } = await response.json();
setError(message || 'Invalid username or password.');
}
} catch (err) {
setError(`An error occurred while trying to log in. Please try again later. ${err}`);
} finally {
setLoading(false);
}
};
return (
<div>
<h1>Login</h1>
<form onSubmit={checkLogin}>
<label>Username:</label>
<input type="text" onChange={(e) => setUsername(e.target.value)} required/>
<label>Password:</label>
<input type="password" onChange={(e) => setPassword(e.target.value)} required/>
<button type="submit">{loading ? 'Signing in...' : 'Sign In'}</button>
</form>
{error && <span className="text-red-500">{error}</span>}
</div>
)
}
export default Login

The above code should now be familiar and fairly easy to follow. If you have worked with React Query (Tanstack) before or any other API try/catch style requests, then it is clear we are pinging our API endpoint and validating a login form, returning a local storage item.

import { useNavigate } from 'react-router-dom';
function Dashboard() {
const navigate = useNavigate();
const handleLogout = () => {
localStorage.removeItem('authToken');
navigate('/admin', { replace: true });
};
return (
<div>
Dashboard
<button onClick={handleLogout}>Logout</button>
</div>
)
}
export default Dashboard

As previously mentioned, including a handleLogout function will allow the user to return to a logged-out state. Here, you can see we are removing the authentication token from local storage, causing the user to be returned to an unauthenticated state.

Overall, this was a great exercise with a lot to learn about how routing works. It taught me some great techniques in dealing with the security of communicating credentials and has given me some topics to research further. I’m excited to see where this journey leads me next.