Build a Login App using React and Express (Step by Step Guide)



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 and cors 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 functions loginUser and registerUser)
  • Shared state from context using useContext
  • Stored JWT tokens using login() from context
  • Redirected the user to /profile route on successful login using useNavigate
  • 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 the Main component, which holds the login/signup form /profile – loads the Profile 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