A secure login system is a fundamental part of any modern web application. It ensures that only authorized users can access private content and sensitive data, keeping user information safe from prying eyes. Many web apps include a mix of public and private pages – public pages open to everyone, and private pages that require a user to log in. In this beginner-friendly tutorial, we’ll walk through building a React login system from scratch using Vite for a fast React setup and JSON Web Tokens (JWT) for authentication.
What Are We Building?
In this step-by-step tutorial, we’ll build a complete React Login System using Vite, Node.js, Express, and JWT tokens. The backend will verify user credentials and issue a JWT (JSON Web Token) upon successful login. On the frontend, our React app will:
- Handle user login input via a form
- Send the login data to the backend API
- Store the JWT token securely on the client side
- Use that token to manage the user’s authentication status
- Protect certain routes so only logged-in users can access them
This project will give you a working authentication flow that’s both secure and scalable, using tools and techniques trusted by real-world developers.
Why Use JWT for Authentication?
JSON Web Tokens (JWTs) are a widely adopted way to handle stateless authentication. Rather than having the server maintain session data, the client holds onto a signed token (the JWT) and includes it in future requests. This token proves the user’s identity.
The server verifies the token
No session data needs to be stored on the server
It’s perfect for scalable, decoupled frontend-backend setups
In our case, once a user logs in successfully, they’ll receive a JWT. This token will then act as their “login badge,” granting access to protected areas of the app.
Friendly Guide for Beginners
Even if you’re new to React, Vite, or authentication, don’t worry — we’ll walk you through each step with code examples, clear commentary, and practical takeaways. By the end of this tutorial, you’ll have a complete, working login system and a solid understanding of how to build secure login flows in modern web apps.
Ready to learn how to protect your frontend like a pro?
Let’s dive in!
Step 1: Setting Up the React Project with Vite
First, we’ll create a new React project using Vite. Vite is a fast development build tool that provides a very quick startup and instant hot module replacement – great for React development. If you’ve used Create React App before, Vite serves a similar purpose but is much faster and leaner.
Initialize a new Vite + React project:
# Use npm to create a Vite project with a React template
npm create vite@latest my-react-login -- --template react
# Move into the project directory
cd my-react-login
# Install dependencies
npm install
# Start the development server
npm run dev
Here’s what we’re doing in the commands above:
npm create vite@latest my-react-login -- --template react
uses the Vite initializer to scaffold a new project named my-react-login with the React template (JavaScript). Vite will generate the project files for us.- We then
cd
into the project folder and runnpm install
to install all the npm dependencies that Vite set up (like React, ReactDOM, etc.). - Finally,
npm run dev
starts the Vite development server. By default, it will tell you the local address (usually http://localhost:5173) where the app is running.
If everything went well, you should see Vite’s welcome screen or a basic React starter when you open the local URL in your browser.
Congrats! You have a React app running. We will use this as the foundation of our login system.
Project Structure:
The Vite template creates a basic structure. Key files to note:
index.html
– the HTML template.src/main.jsx
– entry file where React app is rendered.src/App.jsx
– main App component.src/assets
– static assets (logo etc. from template, which you can remove).package.json
– project dependencies and scripts.
We won’t need the default placeholder content, so feel free to clean up the <App />
component to be a blank starting point or a simple <h1>Hello World</h1>
for now. Next, we’ll set up our backend server for authentication.
Step 2: Creating a Backend with Node.js and Express for JWT Authentication
For our login system, we’ll create a simple backend server using Node.js and Express. The backend’s job is to verify user credentials (username/password) and, if valid, respond with a JWT token that the frontend can use. In a real application, you’d check a database for user info and hash passwords, but to keep things simple, we’ll use a hardcoded user for now.
a. Set up the Express server:
Create a new folder (within the project or separately) for the server. For example, inside the project directory, create a folder called server
. Initialize a Node project and install required packages:
# Move into the server folder
cd server
# Initialize a new Node.js project (creates package.json)
npm init -y
# Install Express (web framework), CORS (to allow cross-origin requests), and jsonwebtoken (for JWT handling)
npm install express cors jsonwebtoken
Now, create a file server.js
(or index.js
) in the server
folder. This will contain our server code:
// server/server.js
const express = require('express');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const app = express();
const PORT = 5000; // our backend will run on http://localhost:5000
app.use(cors()); // Enable CORS for all origins (for development, so Vite can communicate)
app.use(express.json()); // Parse JSON request bodies
// Ideally, store secret in an environment variable. For demo, we'll define a constant:
const SECRET_KEY = 'my_jwt_secret_key'; // In production, keep this safe in .env file
// Dummy user data for demonstration (in real apps, use a database)
const users = [
{ username: 'testuser', password: 'testpass' } // a sample user
];
// Login route - verifies user and returns a JWT
app.post('/login', (req, res) => {
const { username, password } = req.body;
// Find a user that matches the credentials
const user = users.find(u => u.username === username && u.password === password);
if (user) {
// User found – create a JWT token for the user
const token = jwt.sign(
{ username: user.username }, // payload: info we want to include in the token
SECRET_KEY, // secret key to sign token
{ expiresIn: '1h' } // token will expire in 1 hour
);
return res.json({ token }); // send the token back to the client
} else {
// User not found or password didn't match
return res.status(401).json({ error: 'Invalid credentials' });
}
});
// (Optional) A protected route example – this returns a message if user is authenticated
app.get('/protected', (req, res) => {
// Expect token from Authorization header in format "Bearer <token>"
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // split "Bearer token"
if (!token) {
return res.sendStatus(401); // no token, unauthorized
}
// Verify token
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) return res.sendStatus(403); // invalid token
// Token is valid, user info available in `user` variable (payload)
res.json({ message: `Hello ${user.username}, welcome to the protected route!` });
});
});
// Start the server
app.listen(PORT, () => {
console.log(`Auth server running on http://localhost:${PORT}`);
});
Let’s break down what this code is doing:
- We import Express, cors, and jsonwebtoken. Express is our web framework, cors allows cross-origin requests (so our React dev server can call the API), and jsonwebtoken is a library for creating and verifying JWT tokens.
- We create an Express app, enable CORS (in development it’s open to all origins by default here), and use
express.json()
middleware so thatreq.body
will contain parsed JSON. - We define a
SECRET_KEY
which is used to sign JWTs. In production, never hardcode secrets – use environment variables to keep them out of your codebase. But for our tutorial, a hardcoded string is fine. - We create a dummy
users
array with one user (testuser
/testpass
). In a real scenario, you’d query your database to find the user and compare hashed passwords. Here, for simplicity, we just check if the provided credentials match our one demo user. - The POST
/login
route handles login requests:- It reads the
username
andpassword
fromreq.body
(we expect the frontend to send these in a JSON payload). - It tries to find a matching user in our
users
array. - If found, we use
jwt.sign()
to create a token. We include the username in the token’s payload (you could include other info like user ID or roles). We sign it with ourSECRET_KEY
and set it to expire in 1 hour (expiresIn: '1h'
). - We then respond with a JSON containing the token (e.g.,
{ "token": "<jwt-string>" }
). - If the credentials don’t match any user, we send a
401 Unauthorized
status with an error message.
- It reads the
- We also included an example GET
/protected
route. This is a protected API endpoint example that requires a valid JWT:- It looks for an
Authorization
header with a Bearer token (the convention is"Authorization: Bearer <token>"
). - If no token is provided, it returns a 401.
- If there is a token, it uses
jwt.verify()
to check if it’s valid and not expired. If verification fails, send 403 (forbidden). - If the token is valid, it pulls the user info from the token (in our case, the payload has
username
) and responds with a welcome message. In a real app, you might attachreq.user
and next(), etc., but here we just return a sample message. - (This route is just to illustrate how you would protect backend routes with JWT. Our main focus in this tutorial is the login flow and frontend route protection.)
- It looks for an
Now, start the backend server by running:
node server.js
You should see "Auth server running on http://localhost:5000" in the console. Our simple auth server is ready! Keep this running as we move on to building the frontend.
Quick test (optional): You can test the /login
route using a tool like Postman or a simple cURL command:
curl -X POST http://localhost:5000/login \
-H "Content-Type: application/json" \
-d '{"username":"testuser", "password":"testpass"}'
If everything is set up correctly, it should return a JSON with a token. If you change the password to something incorrect, you should get an “Invalid credentials” message. This confirms our backend logic works.
Step 3: Building the React Login Form (Frontend)
With the backend in place, let’s switch to our React app and create a login form for users to enter their credentials. Our goal here is to create a form with fields for username and password, and a button to submit. When the form is submitted, we’ll send the input data to the backend /login
endpoint we just made. If the login is successful, the backend will return a JWT token, and we’ll then need to store that token and mark the user as logged in.
a. Install React Router:
Since we’ll be navigating between a login page and a protected page (dashboard), it’s a good idea to use React Router. Install React Router (v6):
npm install react-router-dom
We will use react-router-dom to manage our routes (like /login
and /dashboard
) and to implement protected routes.
b. Create the Login component:
Inside the src
folder of your React project, create a new file (perhaps src/Login.jsx
). This component will render the login form and handle the login logic.
// src/Login.jsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
function Login() {
// State to hold form input values and any error message
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate(); // hook to navigate to other routes after login
const handleLogin = async (e) => {
e.preventDefault(); // prevent page refresh on form submit
setError(''); // reset error message
try {
// Send login request to our backend
const response = await fetch('http://localhost:5000/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
// HTTP status not OK (e.g., 401 Unauthorized)
throw new Error('Invalid username or password');
}
const data = await response.json(); // parse JSON response
const token = data.token;
console.log('Received token:', token); // for debugging
// Store the token for later use (in localStorage for simplicity)
localStorage.setItem('token', token);
// Redirect to the dashboard or home page after successful login
navigate('/dashboard');
} catch (err) {
console.error('Login failed:', err);
setError('Login failed: ' + err.message);
}
};
return (
<div className="login-container">
<h2>Login</h2>
{/* Simple login form */}
<form onSubmit={handleLogin}>
<div>
<label>Username:</label><br/>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div>
<label>Password:</label><br/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit">Log In</button>
</form>
{/* Display an error message if login fails */}
{error && <p style={{color: 'red'}}>{error}</p>}
</div>
);
}
export default Login;
Let’s go through this Login
component:
- We use React’s
useState
to manage the username and password input values, as well as anerror
message string. - We use
useNavigate
from React Router to programmatically redirect the user after login. - The
handleLogin
function is called when the form is submitted. We calle.preventDefault()
to stop the default form submission (which would reload the page). We also clear any previous error. - We then use the Fetch API to send a POST request to our backend login API:
- URL is
http://localhost:5000/login
(make sure this matches the port where your Express server is running). - We include
Content-Type: application/json
header and useJSON.stringify
to send the username and password in the request body.
- URL is
- After sending the request, we check
response.ok
. If the response status is 401 or any error,response.ok
will be false and we throw an error to be caught in the catch block. - If the response is okay, we parse the JSON to get the
data
. We expectdata.token
to contain the JWT token returned by our server. - We log the token to console (just for debugging; you can remove this later).
- Storing the token: We use
localStorage.setItem('token', token)
. This saves the JWT in the browser’s local storage, so we can retrieve it later. Storing the token means we remember the user is logged in. (We’ll discuss token storage security more in the next step.) - After storing the token, we call
navigate('/dashboard')
to redirect the user to the Dashboard page (which will be a protected route). - We catch any errors, logging them and updating our
error
state to display a message to the user. - The JSX returns a basic form with two inputs (username and password) and a submit button. We display an error message below the form if
error
state is set.
Feel free to style the form with some basic CSS to make it look nicer (not covered here), but even as plain HTML inputs it will do the job.
At this point, our login page is ready to send credentials and handle the response.
Step 4: Creating a Protected Dashboard Page (Frontend)
Next, let’s create a simple Dashboard component which will serve as a protected page that only logged-in users (with a valid JWT) should see. For demonstration, our Dashboard will just display a welcome message and a “Logout” button.
// src/Dashboard.jsx
import React from 'react';
import { useNavigate } from 'react-router-dom';
function Dashboard() {
const navigate = useNavigate();
const handleLogout = () => {
// Remove the token from storage to log the user out
localStorage.removeItem('token');
navigate('/login');
};
return (
<div className="dashboard-container">
<h2>Dashboard</h2>
<p>Welcome! You are logged in.</p>
<button onClick={handleLogout}>Log Out</button>
</div>
);
}
export default Dashboard;
This Dashboard
component is straightforward:
- It includes a Logout button. Clicking the button triggers
handleLogout
, which removes the token from localStorage and then usesnavigate('/login')
to send the user back to the login page. - In a real application, you might also inform the server about the logout (e.g., invalidate the token) or clean up more state, but removing the token on the client is sufficient to “forget” the user’s login for our simple case.
- When the token is removed, our frontend will consider the user as logged out. If they try to access Dashboard again, they should be blocked (we will implement that logic next).
Step 5: Setting Up React Router and Protecting Routes
Now we have a Login page and a Dashboard page, but we need to wire them up with routing and ensure that Dashboard is protected, meaning it should only be accessible if the user is logged in (i.e., if we have a valid token stored). We’ll use React Router to achieve this.
a. Define routes in App.jsx:
Open your src/App.jsx
(which likely still has sample code from the Vite starter). We’ll transform it into our application router. First, modify main.jsx
(or main.js
) to wrap the <App />
with a router:
src/main.jsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<BrowserRouter>
<App />
</BrowserRouter>
);
Wrapping App in <BrowserRouter>
provides routing context to our app.
Now in src/App.jsx, set up the routes:
// src/App.jsx
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import Login from './Login';
import Dashboard from './Dashboard';
// A wrapper for protected routes
function PrivateRoute({ children }) {
const token = localStorage.getItem('token');
// If token exists, render child components, otherwise redirect to login
return token ? children : <Navigate to="/login" replace />;
}
function App() {
return (
<div className="App">
<Routes>
{/* Public route: Login */}
<Route path="/login" element={<Login />} />
{/* Protected route: Dashboard */}
<Route
path="/dashboard"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
{/* Default route: if user is logged in, go to dashboard, else go to login */}
<Route
path="/"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
</Routes>
</div>
);
}
export default App;
Here’s what we did:
- We import
Routes
,Route
, andNavigate
from react-router-dom. We also import our two pages,Login
andDashboard
. - We defined a
PrivateRoute
component. This is a common pattern to protect routes. It checks for a token in localStorage:- If a token is present (meaning the user is logged in, presumably), it renders the child components (in our case, the
<Dashboard />
). - If no token is found, it means the user is not authenticated, so it returns a
<Navigate to="/login" />
component.Navigate
will redirect the user to the/login
route. We also passreplace
prop to avoid adding a new entry in history (so the back button won’t take them right back to the protected page).
- If a token is present (meaning the user is logged in, presumably), it renders the child components (in our case, the
- We set up our
<Routes>
:- The
/login
path renders the<Login />
component (publicly accessible). - The
/dashboard
path is wrapped in<PrivateRoute>
. This means if a user tries to access/dashboard
and they don’t have a token, they will be redirected to/login
. If they do have a token, the Dashboard component will show. - We also handle the root path
/
– in this setup, we’re defaulting it to the dashboard (you could also default to a home page or login). We wrap/
in PrivateRoute as well, so if a user visits the base URL while logged in, they see Dashboard; if not logged in, they go to Login.
- The
- With this, any attempt to navigate to Dashboard without being logged in will be stopped by our PrivateRoute check. 🎯
Note: This simple token check works for our demo. However, it assumes that if a token exists in localStorage, it’s valid. In a production app, you might want to:
- Verify token expiration on the client (JWT tokens have an exp claim) and log out the user if expired.
- Periodically refresh tokens (using refresh tokens or re-login) for long sessions.
- Use more robust state management or context to track auth state instead of hitting localStorage directly every time. For instance, you could store the token in a React context (AuthContext) and provide it to your components. But to keep things beginner-friendly, we’re using the simplest approach. Just keep in mind the client cannot truly verify the token (only the signature by decoding it, which the server would do). So trusting existence is usually okay if tokens are short-lived and your protected API calls will ultimately fail if the token is invalid.
Now our React app has routing and route protection in place. Before testing everything together, let’s quickly recap how the pieces work:
- Initially, user is not logged in (no token). If they try to go to
/dashboard
, PrivateRoute will redirect to/login
. - User goes to
/login
, enters credentials, and clicks Log In (submitting the form inLogin.jsx
). handleLogin
sends a request to the backend:- If credentials are correct, we get back a token. We save it in localStorage and call
navigate('/dashboard')
. - This changes the route to
/dashboard
. React Router will match/dashboard
with our protected route. Now, since we just stored the token, PrivateRoute will see a token and allow rendering<Dashboard />
.
- If credentials are correct, we get back a token. We save it in localStorage and call
- On Dashboard, the user sees the welcome message and can click Log Out, which will remove the token and navigate them back to Login.
- With token removed, if they (or someone) tries to access Dashboard again, they’ll be kicked back to Login. The cycle continues.
Step 6: Securely Storing the JWT and Managing Auth State
Storing the JWT on the frontend is a critical part of a token-based login system, and we have a few options. In our example, we chose to store the token in localStorage for simplicity. This allows us to retrieve it easily and is persistent (it stays even if the user refreshes the page or reopens the browser, until we explicitly remove it). We also could have used sessionStorage
(which lasts until the browser tab is closed) or memory (just a React state or context that resets on refresh).
Security considerations: While convenient, storing JWTs in localStorage has some security implications. The main risk is if malicious script code runs on your site (via XSS attack), it could potentially access localStorage and steal the token. Always be cautious about XSS vulnerabilities in your app. Some developers prefer storing the token in an HTTP-only cookie for security (cookies with HttpOnly
flag aren’t accessible via JavaScript, mitigating XSS token theft). The trade-off is that cookies can be susceptible to CSRF attacks if not handled properly. For beginner purposes, localStorage is okay, but keep these considerations in mind as you advance. Never store truly sensitive data in plain localStorage. Our token is a temporary access key, and even if stolen it expires in an hour as we set up. Consider using short token lifespans and refresh tokens if needed.
Managing login state: In our tutorial, we derived “login state” by checking for a token’s existence. A more React-y way could be to use a Context or global state (like Redux, etc.) to store something like isAuthenticated
or the user
object after login. For example, an AuthContext
could hold user
and token
, and provide login
and logout
functions to components. We kept it simple with localStorage and a quick check in our PrivateRoute. This works, but means any component that needs to know if the user is logged in will also need to check localStorage or we pass that info down.
For completeness, let’s outline a quick way to improve state management:
- Wrap your app in an
AuthProvider
component that usesuseState
to hold the token (initializing from localStorage if present) and provideslogin
andlogout
functions. - When
login
is called (with a token), it saves to localStorage and updates the state, andlogout
clears storage and state. - Use
AuthContext
viauseContext
in components like PrivateRoute or Navbars to know if user is logged in.
This avoids directly accessing localStorage in many places and centralizes auth logic. However, explaining and implementing context is a bit beyond the scope of this tutorial, so our simpler approach is sufficient for now.
Step 7: Testing the React Login System
Now it’s time to run everything together and test our React JWT login system!
Start the backend server: Make sure your Node/Express server (node server.js
) is still running on port 5000. If you stopped it, restart it.
Start the frontend dev server: In the React project directory, run npm run dev
(if it’s not already running). Open your browser to the Vite dev server (usually http://localhost:5173 or whatever it printed in the console).
You should see your app. If you directly went to the root URL, our React Router will attempt to show Dashboard (because of the "/"
route we set), but since there’s no token, it should have redirected you to the /login
page. So likely you’ll see the Login form.
Try logging in using the credentials from our dummy user:
- Username:
testuser
- Password:
testpass
These were defined in our Express server. Enter them and click Log In.
- If all is set up correctly, the page should navigate to /dashboard. You should see the Dashboard component’s content (“Welcome! You are logged in.”).
- Open your browser’s developer console (F12) and check the Application storage for Local Storage. You should see an item
token
with a long string value (that’s your JWT). Also, in the console log, we printedReceived token: ...
when logging in. - Try to refresh the page on /dashboard. Because localStorage still has the token, our PrivateRoute will still allow access. (If you configured the token expiration to 1h, within that time, you’re good. After an hour, that token would be invalid on the backend; our frontend would still think it’s logged in unless we also check expiry, but any API calls to protected endpoints would fail. This is an advanced scenario beyond our simple setup.)
- Now test the Logout button. Clicking it should remove the token and redirect you to /login. Check localStorage again – the token should be gone. If you try to manually go to /dashboard now, you’ll be redirected back to /login because PrivateRoute no longer finds a token.
Everything working? Awesome! You have a basic React login system in place.
Step 8: Summary and Next Steps
In this tutorial, we’ve built a complete React login system using JWT authentication, step by step. Let’s summarize the key takeaways:
- Frontend setup with Vite: We used Vite to quickly bootstrap a React app, giving us a fast development environment.
- Login form component: We created a React
<Login>
component with controlled inputs for username and password, and handled form submission to authenticate the user. - Node/Express backend: We built a simple Express server with a
/login
route that validates credentials and generates a JWT using jsonwebtoken. The backend sends this token back to the client as proof of successful login. - JSON Web Token (JWT): We used JWTs to securely transmit the user’s identity. The token is signed with a secret and includes an expiration. This allows the server to trust the token upon future requests without keeping session state.
- Storing the token: On the frontend, we stored the JWT in
localStorage
(for simplicity in our demo). This lets the app remember the login state even after refresh. We discussed the security implications and alternatives (like HttpOnly cookies or context state) for managing tokens. - Protected routes in React: Using React Router, we implemented protected routes with a
<PrivateRoute>
wrapper that checks for a token. Unauthenticated users are redirected to the login page if they attempt to access restricted pages. - Logout mechanism: We provided a simple logout function that clears the token and navigates back to login, effectively securing the app by requiring re-authentication.
With these pieces, you have a foundational auth system. The concept shown here is similar to how many real-world apps manage authentication (though production systems will have many more checks and features).
Call to Action – Build and Improve Your Own: Now that you have the basics, try extending this project or integrating these concepts into your own app:
- Register new users: Add a signup page and endpoint to create new users (you’ll need to store them, perhaps just in memory or in a file for testing, or connect to a database).
- Hash passwords: Never store plain passwords in a real app. Use a library like bcrypt to hash passwords before storing, and compare hashes on login.
- Refresh tokens: Implement JWT refresh tokens to allow long-lived sessions without risking long-lived access tokens. This typically involves issuing a second token with a longer expiry that can request new access tokens.
- Role-based auth: Expand the JWT payload to include user roles/permissions and conditionally render or restrict routes based on roles (e.g., admin vs regular user pages).
- Improve UI: Style the login form and dashboard. Maybe add a navbar that shows login status, etc., that conditionally displays based on auth state (e.g., “Welcome, [username]” when logged in, and a logout link).
- Handling token expiry: If a token expires while the user is still using the app, you might want to detect that (for example, if an API call returns 403 due to invalid token) and log the user out or prompt re-login.
Building a secure authentication system can be challenging, but you’ve just taken a big step by creating a working login/logout flow with React and JWT. Feel free to use this as a starting point for more complex auth systems. Happy coding!
Want to start from the beginning? checkout our blog post for React Vite Setup. Already have a app then you can skip to how scalable folder structure for React Vite Project works.