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.
What is React Router?
Section titled “What is React Router?”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.
Important Lessons
Section titled “Important Lessons”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:
- Ensure that the communication of those credentials is encrypted.
- Those credentials are not validated or exposed at a
client
level. - Restrict access to the
sever
when it comes to matters related to credentials is critical.
Prerequisites
Section titled “Prerequisites”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.
File Setup
Section titled “File Setup”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
Dependencies
Section titled “Dependencies”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"
Client
Section titled “Client”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:
npm create vite@latest <YOUR_PROJECT_NAME> -- --template react-ts
react-dom
To install react-dom
:
npm install react-dom
react-router-dom
To install react-router-dom
(for routing):
npm install react-router-dom
Server
Section titled “Server”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):
npm install dotenv
cors
To install cors
(allows for cross origin traffic filterting on our server):
npm install cors
express
To install express
(to host our server):
npm install express
jose
To install jose
(JWT utility):
npm install jose
Summary
Section titled “Summary”- 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.
- 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.
- Environment Configuration: Ensure that your .env file is placed in the /server folder for storing sensitive information like SECRET_KEY and other configuration details.
Server Configuration
Section titled “Server Configuration”/server/.env
Section titled “/server/.env”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=adminADMIN_PASSWORD=adminSECRET_KEY=changeme
We have three variables being stored: a username, password and secret which our application will interface with to verify a successful login.
server/server.js
Section titled “server/server.js”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 assignedto run our application.*/
const app = express();/* Whilst this step is optional, it is highly recommended to set cross-originrules to control which origins can access your API and prevent unauthorisedaccess 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 rootdomain, ensuring consistency across different environments and potentialvariables. */const corsOptions = { origin: 'http://localhost/', methods: ['GET', 'POST'], allowedHeaders: ['Content-Type', 'Authorization'],};// Advising the application to use CORS and expressapp.use(cors(corsOptions));app.use(express.json());// Setting our variables from our .env fileconst 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 thevalidity 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 routesapp.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 canchange or be removed for cloud instances like Heroku)*/app.listen(3000, () => { console.log('Server running on port 3000');});
/api/login
Section titled “/api/login”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:
- Defining the Login Route:
- A POST request is set up at the /api/login endpoint to handle login requests.
- 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.
- 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.
- 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.
- 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.
verifyToken
Section titled “verifyToken”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:
- 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.
- Checking if the Token Exists:
- We ensure that the token exists. If it’s missing or
null
, the middle ware responds with a403 Forbidden
status and an error message saying “No token provided.”
- 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 callingnext()
.
- 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.
Summary
Section titled “Summary”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.
Client
Section titled “Client”App.tsx
Section titled “App.tsx”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.
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 Statements
Section titled “Import Statements”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:
- Global CSS: The
App.css
file for styling, which is standard in Vite’s React TypeScript setup. - React Router Components: We import
Router
,Routes
, andRoute
fromreact-router-dom
to enable routing functionality in the application. - Custom Components: We import the
Home
,Login
, andDashboard
components from the components folder to render the respective pages. ProtectedRoutes
: This is a custom utility component that handles protecting certain routes, ensuring only authenticated users can access them.
Understanding BrowserRouter
Section titled “Understanding BrowserRouter”Router
Section titled “Router”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.
Routes
Section titled “Routes”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.
JSX Return
Section titled “JSX Return”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 aProtectedRoutes
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.
Summary
Section titled “Summary”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.
/utils/ProtectedRoutes.tsx
Section titled “/utils/ProtectedRoutes.tsx”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 Statements
Section titled “Import Statements”import { Outlet, useNavigate } from "react-router-dom";
We are not introduced to two new imports from react-router-dom
:
- 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. - Outlet: The
Outlet
element returns nestedRoutes
of the parent component.
ProtectedRoutes() function
Section titled “ProtectedRoutes() function”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:
- Setting Token and Navigation Variables:
- The function will be relying on a locally stored
token
fromlocalStorage
to determine the users authorisation status. - The function needs a
navigate
variable to triggeruseNavigate
- Token Validation:
- The
useEffect
hook is responsible for the validation based on changes totoken
ornavigate
- 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
- 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.
Dashboard Login and Home:
Section titled “Dashboard Login and Home:”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.
/components/Login.tsx
Section titled “/components/Login.tsx”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.
/components/Dashboard.tsx
Section titled “/components/Dashboard.tsx”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.
Conclusion
Section titled “Conclusion”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.