This content originally appeared on DEV Community and was authored by Alexandru Ene
Introduction
In this tutorial, I will show you step by step how to create a login app using React for frontend and Express for backend.
Note This guide is not meant to teach you React and Express from scratch. I assume that if you are here, you already have a basic understanding of both technologies.
What I will focus on instead is helping you connect React and Express together to create a fullstack login system. I’ll do my best to explain things as simply and clearly as possible.
And… It is going to be a long one. So I will take the blame for it, but hopefully you will learn something and I will learn something from your feedback.
What We’ll Cover
1. Frontend – React
– Creating a react application with vite tool
– Using components and props to share and manage state
– Controlled components for handling forms
– React hooks: useState
, useEffect
, useContext
, useNavigate
– Module imports/exports
– Error handling with try...catch
blocks
– Sending server requests with fetch
– Storing the JWT token in localStorage
– Persisting user sessions across page reloads
– Routing using React Router
– Structuring and organizing the app
2. Backend – Express
– How to create a basic Express server
– Parsing JSON request bodies using express.json()
– Enabling cross-origin requests with CORS
– Creating /register
and /login
routes
– Hashing passwords using bcryptjs
– Storing user data temporarily in memory
– Verifying user credentials during login
– Creating JWT tokens with jsonwebtoken
– Sending the token to the frontend
– Middleware for protecting routes using token verification
– Creating a protected /profile
route that only works with a valid token
Let’s get started!
Project Setup
I will live a link here for the repository, in case you want to play with it: Login App
We will start by creating two folders: one for the frontend and one for the backend. We should place them inside a parent folder, which you can choose any names for, but I will call it ‘login-app’.
You can use your favorite code editor like Sublime or VS Code. Personally I will stick with VS Code.
1. Backend – Express Setup
Now let’s open the terminal. If you are in VS code, a quick way is to press: Ctrl + `
(on Windows) or Cmd + `
(on Mac)
Start by creating a folder for the backend, then open the folder and then initialize node:
mkdir backend
cd backend
npm init -y
Install the necessary dependencies:
npm install express bcryptjs jsonwebtoken cors
Since we’ll be using ES modules, make sure your package.json
file includes this line:
"type": "module"
And also inside package.json
file change "main": "index.js"
to "main": "server.js"
.
Now create a file called server.js
and add the following code:
// import the required packages
import express from 'express';
import cors from 'cors';
// initialize express app
const app = express();
// allow frontend to connect with the backend
app.use(cors());
// Allow express to parse JSON
app.use(express.json());
// simple route for testing the server
app.get('/', (req, res) => {
res.send('Backend is running!');
});
// start server on port 5000
const PORT = 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
This is our basic express server setup.
What we did:
- Imported
express
andcors
at the top - Enabled CORS to allow frontend to connect to our server
- Enabled JSON parsing using
express.json()
- Created a simple test endpoint at
/
- Started the server on port
5000
Now let’s test it. Start the backend server:
node server.js
In your terminal you should see something like:
PS C:\Users\Alexandru\Desktop\login-app\server> node server.js
Server running on port 5000
Our backend is now live at http://localhost:5000
2. Frontend – React Setup with Vite
Now let’s create the frontend app using Vite.
First press Ctrl + c
or Cmd + c
in the terminal to close the connection. Let’s navigate to parent folder using cd ..
. Then let’s run npm create vite@latest
. Name your folder ‘frontend’, choose React and JavaScript, then navigate to frontend
folder and install the dependencies. Like so:
cd ..
npm create vite@latest
cd frontend
npm install
npm install jwt-decode react-router
Then start the React server:
npm run dev
The frontend will be available at http://localhost:5173 (or something similar) and you should see something like this:
VITE v6.3.5 ready in 1046 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
Our folder structure at this point should look mostly like this:
my-login-app/
├── backend/
│ ├── server.js
│ └── package.json
├── frontend/
│ ├── src/
│ ├── public/
│ └── vite.config.js
Now, before moving forward, you would want to close the server, again. In your terminal press Ctrl + C
or Cmd + C
. Why? Our code is broken for now, because we miss essential parts and you will get nasty error popping up in the console. So bear with me, we will get to the end together.
Add styles to our app
Let’s add some styles for our UI. Add this code to a styles.css
file inside your src
folder:
:root {
box-sizing: border-box;
}
*, *::before, *::after {
box-sizing: inherit;
}
body {
margin: 0;
background-color: hsl(240, 91%, 10%);
color: white;
font-family: Arial, Helvetica, sans-serif;
overflow: clip;
}
input {
font-family: inherit;
}
header {
display: flex;
justify-content: center;
flex-wrap: wrap;
white-space: nowrap;
gap: 1em;
padding: 1em;
text-align: center;
font-size: clamp(.75rem, 1rem + 1vw, 1.5rem);
img {
width: 25%;
max-width: 100px;
aspect-ratio: 1 / 1;
}
}
.login-form {
display: flex;
flex-direction: column;
align-items: center;
gap: 1em;
margin-top: 2em;
}
.input-wrapper {
display: flex;
flex-direction: column;
gap: 1em;
input {
width: 100%;
max-width: 20em;
padding: .25em 1em;
border-radius: 10px;
outline: none;
font-size: 1.5rem;
text-align: center;
transition: 300ms;
}
input:focus,
input:hover {
box-shadow: 0 5px 15px 1px hsl(30, 92%, 55%);
}
}
.login-btn {
margin-top: 1em;
padding: .25em 1.5em;
border: none;
border-radius: 10px;
font-family: inherit;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
transition: 200ms;
}
.login-btn:hover,
.login-btn:focus {
box-shadow: 0 5px 15px 1px hsl(30, 92%, 48%);
}
.signup-wrapper {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 1em;
margin-top: 3em;
}
.signup-btn {
margin-top: 0;
font-size: 1rem;
}
.status {
height: 20px;
margin-top: 1em;
text-align: center;
}
.error-status {
color: hsl(0, 100%, 50%);
}
.ok-status {
color: hsl(120, 97%, 45%);
}
.greeting {
text-align: center;
}
.logout-btn {
display: block;
width: 10em;
margin-inline: auto;
font-size: 1rem;
}
You can of course design the app to your preferences, but I wanted to make sure we have a little bit of visuals here and to make our life a little easier.
Build The Login/Register Form
Let’s create a folder called components
inside src
folder. Once we are inside the components
folder, let’s add the following files: Form.jsx
, Main.jsx
and Header.jsx
. We’ll take them one by one, but for now let’s focus on building our login form.
In your Form.jsx
file, add the following:
// our reusable form component for login and register
export default function Form(props) {
return (
<form className="login-form" onSubmit={props.handleSubmit}>
<div className="input-wrapper">
<input
onChange={props.handleUsernameInput} // update username state on change
value={props.username} // controlled input by username state
type='text'
placeholder='Username' />
<input
onChange={props.handlePasswordInput} // update password state on change
value={props.password} // controlled input by password state
type='password'
placeholder='Password' />
</div>
<button className="login-btn">
{props.submitBtnText}
</button>
</form>
)
}
What we did:
We created a form component that receives props from its parent Main.jsx
to manage state and actions:
handleSubmit
: Function called when the form is submitted.
handleUsernameInput
and handlePasswordInput
: Functions to update the username and password states on input change.
username
and password
: State values controlled by the parent component, passed down as props.
submitBtnText
: Text displayed on the submit button (“Login” or “Register”), so the form can be reused in login and register contexts.
Build The Header Component
Not our main focus here, but easy to implement and will give our app some design to look a little better.
Add this code in your Header.jsx
file:
// insert the import here
export default function Header() {
return (
<header>
// insert the img tag here
<h1>Login App</h1>
</header>
);
}
Make sure you have an ‘images’ folder inside src
folder and a picture for you to use. Feel free to choose whatever you like.
Note: I am getting an error when trying to publish the post because I tried to import the image inside the file, that’s why that import line and image tag is removed. But on your local machine, add the import to your file and write the img src attribute like this:
import logo from 'some-source'
<img src={logo} alt="logo" />
It is better to import the images instead of passing a url path to src attribute:
- The images will be optimized, which will help with performance
- It ensures path safety, we avoid incorect relative paths
- Safer for refactoring: renaming the image or changing its location will break the imports, so better error handling
Set up Authentication Logic / Build Main Component
Let’s move forward with our Main.jsx
file. Add this code to your Main component
:
// Import hooks and dependencies
import { useState, useContext } from 'react';
import { useNavigate } from 'react-router';
import { ProfileContext } from '../contexts/profileContext.jsx';
import Form from './Form.jsx';
import { registerUser, loginUser } from '../services/authServices.js';
export default function Main() {
// Access the login function from context
const { login } = useContext(ProfileContext);
// Hook from React Router to navigate pages
const navigate = useNavigate();
// State for form input
const [ username, setUsername ] = useState('');
const [ password, setPassword ] = useState('');
// State to store server response or error
const [ data, setData ] = useState('');
const [ err, setErr ] = useState(null);
// State to toggle between login and signup/register mode
const [ isLoginMode, setLoginMode ] = useState(true);
// Texts that change depending on login/signup mode
const submitBtnText = isLoginMode ? 'Login' : 'Sign up';
const toggleBtnText = isLoginMode ? 'Sign up' : 'Login';
const modeText = isLoginMode ? 'No account yet?' : 'Already have an account?';
// Form submit handler
const handleSubmit = async (e) => {
e.preventDefault();
try {
// Decide which function to call based on mode
const calledFunction = isLoginMode ? loginUser : registerUser;
const result = await calledFunction(username, password);
// Store server response
setData(result);
// If login/signup was successful
if (result.token) {
login(result.token); // Save token in context + localStorage
navigate('/profile'); // Redirect to protected profile page
}
} catch (err) {
console.log(err);
setErr(err);
}
};
// Toggle between login and signup modes
const handleSignUpText = () => {
setLoginMode(prev => !prev);
};
// Handle input updates
const handleUsernameInput = (e) => {
setUsername(e.target.value);
};
const handlePasswordInput = (e) => {
setPassword(e.target.value);
};
return (
<>
{/* Display server messages */}
<div className={data.success ? 'status ok-status' : 'status error-status'}>
{data.message}
</div>
{/* Our reusable form component */}
<Form
handleSubmit={handleSubmit}
handleUsernameInput={handleUsernameInput}
handlePasswordInput={handlePasswordInput}
username={username}
password={password}
isLoginMode={isLoginMode}
submitBtnText={submitBtnText}
/>
{/* Toggle login/signup button */}
<div className="signup-wrapper">
<p>{modeText}</p>
<button onClick={handleSignUpText} className="login-btn signup-btn">
{toggleBtnText}
</button>
</div>
</>
);
}
I know this looks heavy and it is because our Main component is doing the hard work for our frontend, but there are a few things that happen here and I will list and explain them.
What we did:
- Handled form input using
useState
- Toggled between login and signup mode
- Sent form data to the backend using
fetch
(via helper functionsloginUser
andregisterUser
) - Shared state from context using
useContext
- Stored JWT tokens using
login()
from context - Redirected the user to
/profile
route on successful login usinguseNavigate
- Renderd a reusable component and displayed dynamic messages
- Handled errors with
try...catch
So when we start our app, what we see is two inputs controlled by state, username
and password
respectively. And we have two buttons. The top button is the submit button and the bottom one’s role is too toggle between submit modes: between logging and registering a user. The toggle mode button will also update the texts properly.
If you don’t have an account you click on the button from below and you change the state: isLoginMode
is now set to false and you are now in register mode. If you now click the submit button, you will call handleSubmit()
function. This will check for the mode you are in (for now it’s registerMode) and will call the proper helper function (loginUser or registerUser which we’ll talk about soon) with two arguments, which are the values from inputs held in username
and password
state.
You will receive a response from the server, which it is saved in data
or err
states and you will see some dynamic text appear on the screen, could be an error message or a success one.
If you want to login, suppose you already have an account, when you submit, you call handleSubmit()
, which will call loginUser()
with username and password, you receive the token from the server, you save it in localStorage
via login()
function and you redirect to /profile
route.
If it is still too hard for you to understand, just know that it will make more sense when we put all the pieces together and we make a recap. I know I was a little verbose, I felt like I should talk more, but hopefully it makes more sense now.
Create Profile Page, Context and Api Services
Let’s go to src
folder and create a pages
folder, a contexts
folder and a services
folder.
Inside pages
folder let’s create a file called Profile.jsx
. Inside contexts
folder let’s create a file called ProfileContext.jsx
. And lastly, let’s create a authServices.js
file inside services
folder.
Profile.jsx
This is the component we redirect users to after they log in. It displays a greeting using the user’s name (decoded from the JWT), and provides a logout button.
Here’s the code:
// import required modules and hooks
import { ProfileContext } from "../contexts/ProfileContext.jsx";
import { useContext } from 'react';
import { useNavigate } from 'react-router';
export default function Profile() {
// Extract user info and logout function from context
const { user, logout } = useContext(ProfileContext);
const navigate = useNavigate();
// Handler for logout button
const handleLogout = () => {
const confirmation = confirm('Are you sure you wanna logout?');
if (confirmation) {
logout(); // Remove token from localStorage
navigate('/'); // Redirect to home page (login screen)
}
};
return (
<>
{/* Display a greeting using username */}
<h1 className="greeting">Hello, {user.user || 'Guest'}!</h1>
{/* Logout button */}
<button onClick={handleLogout} className="login-btn logout-btn">Logout</button>
</>
);
}
What we are doing here:
- We access the user information and logout function using useContext(ProfileContext)
- The user’s name was extracted earlier from the JWT and stored in context
- handleLogout() removes the token from localStorage and navigates back to the main page
- We use conditional rendering to show ‘Guest’ in case something goes wrong and user.user is empty
- This component only becomes meaningful once the token is successfully stored and decoded — so it relies on our authentication flow working correctly
Context.jsx
Let’s write one more! Add this to you Context file:
// Import dependencies
import { createContext, useState, useEffect } from "react";
import { jwtDecode } from 'jwt-decode';
// Create the context object
export const ProfileContext = createContext();
// Define the provider component
export const ProfileProvider = ({ children }) => {
// Store user info in state
const [ user, setUser ] = useState('');
// Function to log in a user
const login = (token) => {
localStorage.setItem('token', token); // Save token to localStorage
const decoded = jwtDecode(token); // Decode JWT to get username
setUser({ user: decoded.username }); // Store username in context state
};
// Function to log out a user
const logout = () => {
localStorage.removeItem('token'); // Clear token from localStorage
};
// On initial load, check if there's a token and decode it
// Persistent login across page refresh
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
const decoded = jwtDecode(token); // Decode token on page refresh
setUser({ user: decoded.username }); // Restore user context state
}
}, []);
// Provide context value to children
const value = { user, login, logout };
return (
<ProfileContext.Provider value={ value }>
{ children } {/* Render all children inside this provider */}
</ProfileContext.Provider>
);
};
What we did:
- Created a global context: a context object we can use with React’s useContext() hook anywhere in the app. It allows components to access shared data — in this case, the logged-in user’s info and the ability to log in or out
- Set up the context provider: the ProfileProvider component wraps our entire app (check App.jsx), and gives access to user, login(), and logout() to any component that consumes the context
- Stored JWT token so that a user logs in, their JWT token is stored in localStorage, which means the login can persist even after a page reload
authService.js
We are getting closer to the end of our frontend work. Add this to authService.js file:
// Base URL of our Express backend server
const url = 'http://localhost:5000';
// Function to handle user registration
const registerUser = async (username, password) => {
// error handling
try {
const res = await fetch(`${url}/register`, {
method: 'POST', // HTTP POST request
headers: { 'Content-Type': 'application/json' }, // Sending JSON
body: JSON.stringify({ username, password }) // Payload
});
// Check if request was successful
if (!res.ok) {
console.error('Registration failed');
return res.json(); // Still return the error message
}
return res.json(); // Return server response (success, token, etc.)
} catch (err) {
console.error(err);
}
};
// Function to handle user login
const loginUser = async (username, password) => {
// error handling
try {
const res = await fetch(`${url}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
console.error('Login failed');
return res.json(); // return the error message
}
return res.json(); // Server sends back token if login successful
} catch (err) {
console.error(err)
}
};
// Export both functions so we can use them elsewhere
export { registerUser, loginUser };
What we did
- Sent a POST request to /register with username and password in the body. The server hashes the password and stores the user. If successful, it responds with a success message. If not, an error is returned
- Sent a POST request to /login with the same credentials. The server checks if the user exists and if the password matches. If valid, it sends back a JWT token. We’ll later save this token to localStorage so the frontend knows the user is authenticated
- Placed these API functions in a separate authService.js file to keep our React components clean, to encourage reusability and to make it easier to update API logic in one place
What else? We structured the app, into folders and files. By doing this we created a more scalable app, easier to debug and easier to read.
Update App.jsx and Main.jsx
Our app is still broken at this point. We have to make a few changes to App.jsx
and main.jsx
, which we didn’t touch since we created the React application. So let’s go first to App.jsx and modify it so it will look like so:
// Import React components and routing tools
import Header from "./components/Header.jsx";
import Main from "./components/Main.jsx";
import Profile from "./pages/Profile.jsx";
import { BrowserRouter as Router, Routes, Route } from 'react-router';
// Import the context provider to manage global user state
import { ProfileProvider } from "./contexts/ProfileContext.jsx";
function App() {
return (
<Router>
{/* Header that is always visible */}
<Header />
{/* Wrap the app in ProfileProvider to share user state */}
<ProfileProvider>
<Routes>
{/* Home route – renders login/signup form */}
<Route path='/' element={<Main />} />
{/* Protected profile route */}
<Route path='/profile' element={<Profile />} />
</Routes>
</ProfileProvider>
</Router>
);
}
export default App;
What we did here
- Used
<Router>
,<Routes>
, and<Route>
from react-router to enable routing between pages - Added the Header: this component displays the app’s title and is shown on all pages
- Wrapped the app with ProfileProvider, this gives all components access to the global user context. It lets us share login/logout logic across the app
- Defined Routes:
/
– loads theMain component
, which holds the login/signup form/profile
– loads theProfile component
, which shows the user’s profile page
Let’s continue with main.jsx file:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
One more thing to do is to remove these files: App.css
and index.css
. These are some styles from React and Vite, which we don’t need since we have our own.
Backend Logic – Express Setup
We are going now to implement the backend logic into our app. This is how server.js
file should look. Let’s update it as following:
// Import necessary packages
import express from 'express';
import jwt from 'jsonwebtoken';
import bcryptjs from 'bcryptjs';
import cors from 'cors';
// Temporary in-memory user storage
const users = [];
// Secret key for signing JWTs
const SECRET_KEY = 'my_secrete_key';
// Initialize the Express app
const app = express();
// Middleware to handle CORS and parse incoming data
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Register Route
app.post('/register', async (req, res) => {
try {
const { username, password } = req.body;
// Check if user already exists
const user = users.find(user => user.username === username);
if (user) return res.status(400).json({ success: false, message: 'User already exists' });
// Hash the password before storing it
const salt = await bcryptjs.genSalt(10);
const hashedPass = await bcryptjs.hash(password, 10);
// Store user in memory
users.push({ username, password: hashedPass });
res.status(201).json({ success: true, message: 'New user created!', users });
} catch (err) {
console.error(err);
res.status(500).json({ success: false, message: 'Internal server error' });
}
});
// Login Route
app.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
// Check if the user exists
const user = users.find(user => user.username === username);
if (!user) return res.status(404).json({ success: false, message: 'User not found' });
// Verify password
const isPassValid = await bcryptjs.compare(password, user.password);
if (!isPassValid) {
return res.status(404).json({ success: false, message: 'Invalid password' });
}
// Create a JWT token
const payload = { username };
const token = jwt.sign(payload, SECRET_KEY, { expiresIn: '24h' });
res.status(200).json({ token, success: true, message: 'Logged in successfully!' });
} catch (err) {
console.error(err);
res.status(500).json({ success: false, message: 'Internal server error' });
}
});
// Middleware to Protect Routes
const loginMiddleware = async (req, res, next) => {
const authorization = req.headers['authorization'];
const token = authorization && authorization.split(' ')[1];
// Check if token exists
if (!token) {
return res.status(401).json({ success: false, message: 'Inexistent or invalid token' });
}
// Verify token
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) return res.status(403).json({ success: false, message: 'Invalid token' });
req.user = user;
next();
});
};
// Protected Profile Route
app.get('/profile', loginMiddleware, (req, res) => {
const username = req.user.username;
res.json({ success: true, message: `Welcome, ${username}!` });
});
// Start the Server
const PORT = 5000;
app.listen(PORT, () => {
console.log(`Server running on port: ${PORT}`);
});
What we did
- For user registration, we check for duplicate usernames. We hash the password before storing. We store the user in an in-memory array (only for simplicity)
- For our Login system, we verify user existence and correct password. We generatea a JWT token for authenticated access.
- Protected route
/profile
can only be accessed if a valid JWT is sent in the request headers - Middleware: validates incoming JWT tokens to protect routes, in our case
/profile
Note In a real-world app, we would:
- Store users in a database (not memory)
- Use HTTPS and stronger secrets
- Refresh tokens
- Handle errors more robustly
- Use a different structure for backend logic, like moving routes and middleware to their own files, storing secret keys in a .env file, using a database (like MongoDB) and so on
Important: Let’s start both servers for the frontend and for the backend to test our app!
Go to VS Code and open two terminals, we will use one for backend and the other one for frontend. You can rename them if you want. Go to one of them and run these commands:
cd backend
node server.js
Now open the other terminal and run these commands:
cd frontend
npm run dev
Now you should have two terminals with these results:
For frontend:
VITE v7.0.0 ready in 217 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
For backend:
PS C:\Users\Alexandru\Desktop\login-app\backend> node server.js
Server running on port: 5000
What this means is that both servers are now working and we could use our app! To open the app go to this link in your browser http://localhost:5173/ or whatever link vite gave you.
Now try to create an account and login with the wrong credentials or the good ones. Write on purpose a bad password or a bad username and look at the dynamic status messages. Those comes from the server, check the routes in the server.js.
Try to write this app by yourself from scratch, no help at first. See how much you can remember. Read the app again and again until you understand the code.
Just one more small thing, if you care, just change the title of the app in index.html
file to something like ‘Login App’ or anything, it will look better!
Final Section
1. Recap of What We Built
We used:
- React (Vite) for the frontend
- Express for the backend
- JWT for authentication
- Context API for managing user state
- LocalStorage for session persistence
- Protected routes for user-only access
We covered how to:
- Handle login and registration
- Hash passwords securely
- Send and store JWTs
- Restrict access based on authorization
- How to structure our frontend
2. Next Steps and Sugestions
Now that you have this foundation, here are some ideas to work on:
- Store users in a real database (MongoDB, PostgreSQL)
- Add form validation both for backend and frontend
- Try to improve security a little: use HTTPS, rate limiting
- Add refresh tokens for better session handling
- Do Deploy your app (like Vercel + Render or Netlify + Railway)
- Improve the UI with a CSS framework like Tailwind or Bootstrap or even plain css, like do better than me!
- Change that nasty confirm pop up that appears when you want to log out! I know, I missed that! Or I did it on purpose to give you some homework…
Remember, the best way to learn more is to keep building and experimenting. Try breaking this app and fixing it again. That’s how you learn stuff! And be proud of what you achieved!
3. Final Words
If you made it this far — thank you!
I really hope you learned something valuable, and I’d love to hear your thoughts:
Did I miss something?
Was this tutorial helpful?
What could be clearer?
What topic should I cover next?
Please leave a comment, share your feedback (even the bad stuff), ask me anything or just say hi. I’d love to connect!
If you want to support me:
You can find me: here
Drop a
Follow me for more tutorials like this
Share this with someone who might find it useful
Thanks again for reading — and happy coding!
This content originally appeared on DEV Community and was authored by Alexandru Ene