How to Build a Website with User Accounts
Learn how to build a website with user accounts using HTML, CSS, and JavaScript.
Recommended
Recommended Web Hosting
The Best Web Hosting - Free Domain for 1st Year, Free SSL Certificate, 1-Click WordPress Install, Expert 24/7 Support. Starting at CA$2.99/mo* (Regularly CA$8.49/mo). Recommended by WordPress.org, Trusted by over 5 Million WordPress Users.
Adding a login screen to your website changes it from a static brochure into an interactive community. If you want to know exactly how-to-build-a-website-with-user-accounts, you are in the exact right place. Research from 2023 shows that websites offering personalized user accounts see a 30% increase in daily active users compared to static sites. People stay longer, click more, and trust you more when they have a dedicated space on your platform.
Building this functionality takes roughly 3 to 4 hours if you have a basic grasp of HTML, CSS, and JavaScript. We will build a custom authentication system using Node.js for the backend. You will spend exactly $0 on software because we are using 100% open-source tools.
This guide breaks down the exact steps, security requirements, and code snippets you need to let users sign up, log in, and view their own profiles. We will cover everything from setting up your local files to hashing passwords and connecting to a real database. By the end, you will have a fully functioning, secure user authentication system that you built from scratch.
Understanding the Core Architecture of Web Authentication
Before you write a single line of code, you need to understand how web authentication actually works. The web is inherently stateless. This means that your server treats every single request as completely independent. When you load a homepage, the server forgets you exist the millisecond it sends the HTML to your browser.
This stateless design makes the web very fast, but it creates a massive problem for user accounts. If the server forgets you immediately, how does it know you are logged in when you click on your profile page? The answer lies in sessions, cookies, and tokens.
When a user types in their email and password, your backend server checks those credentials against the database. If the password matches, the server creates a special passcode. This passcode is sent back to the user’s browser. The browser saves this passcode in a small text file called a cookie.
Every time the user clicks a new page, the browser silently sends that cookie back to the server. The server reads the passcode, verifies it is legitimate, and says, “Ah, this is user number 402.” This entire exchange happens in about 150 milliseconds.
There are two main ways to handle this process today. The older method uses “Server-Side Sessions,” where the passcode is just a random ID string, and the server keeps a master list of who owns each ID. The modern method uses “JSON Web Tokens” (JWT).
With JWT, the server takes the user’s ID, an expiration date, and a secret password, and scrambles them together into a long string of letters and numbers. The server does not need to remember anything. When the browser sends the JWT back, the server uses math to verify the string was created using the secret password. We will use JWT for this project because it scales better and is easier to build.
Choosing Your Technology Stack and Database
You have hundreds of programming languages, frameworks, and databases to choose from. For this project, we are going to use a very specific, highly tested stack that balances performance, security, and ease of learning.
For the frontend, we will use plain HTML, CSS, and JavaScript. We do not need heavy frameworks like React or Angular for a simple authentication portal. Keeping the frontend simple reduces load times and lets you see exactly how the browser communicates with the server.
For the backend, we will use Node.js with the Express.js framework. Node.js allows you to write server-side code using JavaScript. This means you only have to learn one programming language to build the entire website. Express.js is a minimalist web framework that makes writing server routes take 5 minutes instead of 5 hours.
For the database, we will start with a local array, but we will discuss how to move to PostgreSQL. PostgreSQL is an open-source relational database. It holds over 30 years of active development and handles complex queries in under 2 milliseconds on modern hardware.
Using a custom backend gives you total control over the database structure, server logic, and security rules. However, you might decide that managing a server sounds like too much work. In that case, you can use a Backend-as-a-Service (BaaS) provider.
Services like Firebase, Supabase, or Auth0 handle the complex security parts for you. You plug their pre-written code into your frontend, and they store the passwords securely on their own servers. To help you decide, here is a detailed comparison matrix of the most common approaches. We compare the setup time, estimated monthly cost for 10,000 active users, and the exact use case for each path.
| Technology Stack | Setup Time | Monthly Cost (10k Users) | Best Use Case |
|---|---|---|---|
| Custom Node.js + PostgreSQL | 3 to 4 hours | $5.00 (DigitalOcean droplet) | Developers who want 100% control over user data and server logic. |
| Firebase Authentication | 15 to 30 minutes | $0.00 (Free tier covers 50k users) | Mobile apps or simple web apps needing fast social login integration. |
| Auth0 | 45 to 60 minutes | $35.00 (Essential tier) | Enterprise applications requiring strict compliance (SOC2, HIPAA). |
| Supabase | 20 to 40 minutes | $0.00 (Free tier covers 50k users) | Open-source projects wanting a PostgreSQL database with built-in auth. |
If you want to learn the actual mechanics of web security, build the custom Node.js route with me. If you are building a commercial product on a tight deadline, consider copying the Firebase or Supabase documentation later. For now, let’s write the code ourselves.
Step 1: Set Up Your Development Environment
First, you need to prepare your local machine for web development. A proper environment ensures your code runs smoothly and catches errors early. Setting this up takes about 20 minutes, but it saves you hours of frustration later.
Download and install Visual Studio Code (VS Code). It is completely free and holds an impressive 74% market share among professional developers according to the 2023 Stack Overflow Developer Survey. Open VS Code and install the “Live Server” extension by Ritwick Dey. This extension automatically refreshes your browser when you save a file, eliminating the need to press F5 constantly.
Next, you need Node.js. Navigate to the official Node.js website and download the Long Term Support (LTS) version. As of late 2023, the LTS version is 20.x.x. LTS versions are strictly tested for 30 months and guarantee stability. Installing Node.js also installs npm (Node Package Manager), which hosts over 2.1 million software libraries.
Verify the installation by opening your computer’s terminal. On Windows, press the Windows key, type “cmd”, and press Enter. On Mac, press Command + Space, type “Terminal”, and press Enter. Type node -v and press Enter. You should see a version number print to the screen. Do the same for npm -v.
Create a main folder for your project on your desktop. Name it user-account-site. Open this folder directly in VS Code by dragging the folder icon over the VS Code window. Inside this folder, create three empty files: index.html, style.css, and script.js.
These three files represent the entire frontend of your application. The HTML file holds the text and structure. The CSS file holds the colors and layout. The JavaScript file holds the interactive buttons and data transfer logic.
Step 2: Create Your HTML Structure
HTML provides the skeleton for your user accounts. We will build a single page that handles both user registration and login. Keeping everything on one page simplifies the initial code structure and provides a smooth experience without page reloads.
Open your index.html file. Add the standard document structure, ensuring you include the correct viewport meta tag for mobile responsiveness. Over 55% of web traffic comes from mobile devices, so ignoring mobile screens hurts your audience immediately. The viewport tag tells the browser to scale the website to fit the width of the phone screen exactly.
Create a container for your forms. You will need a form asking for an email and a password to sign up. You will also need a separate form for returning users to log in. We will hide the login form initially using CSS to keep the interface clean.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Build a Website with User Accounts</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Welcome to the Platform</h1>
<!-- Registration Form -->
<form id="signupForm">
<h2>Create Account</h2>
<input type="email" id="signupEmail" placeholder="Enter Email" required>
<input type="password" id="signupPassword" placeholder="Create Password" required minlength="8">
<button type="submit">Sign Up</button>
<p>Already have an account? <a href="#" id="showLogin">Log in here</a></p>
</form>
<!-- Login Form (Hidden by default) -->
<form id="loginForm" style="display: none;">
<h2>Welcome Back</h2>
<input type="email" id="loginEmail" placeholder="Enter Email" required>
<input type="password" id="loginPassword" placeholder="Enter Password" required>
<button type="submit">Log In</button>
<p>Need an account? <a href="#" id="showSignup">Sign up here</a></p>
</form>
<!-- User Dashboard (Hidden by default) -->
<div id="dashboard" style="display: none;">
<h2>User Dashboard</h2>
<p>Welcome, <span id="userEmail"></span>!</p>
<button id="logoutBtn">Log Out</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
Notice the minlength="8" attribute on the password field. This basic HTML5 validation prevents users from submitting passwords shorter than 8 characters. It provides an instant, zero-effort security boost on the frontend. The browser will automatically show a warning message if the user tries to submit a shorter password.
We also use type="email" instead of type="text". This forces the browser to check if the input contains an “@” symbol and a domain name. It stops users from accidentally submitting garbage data like “johnsmith” instead of “johnsmith@email.com”. These small details prevent about 15% of standard user errors.
The <script src="script.js"></script> tag sits at the very bottom of the body. We place it at the bottom so the browser finishes drawing the HTML structure before it tries to load the JavaScript. If you put the script tag in the head, the JavaScript might look for a button that doesn’t exist yet, causing crashes.
Step 3: Style with CSS for Trust and Usability
Plain HTML forms look dated. Styling your website with CSS makes the forms visually appealing and easy to use. Good design directly impacts user trust. A study by Stanford University found that 75% of users judge a company’s credibility based purely on visual design. If your login form looks like it was built in 1998, users will not trust you with their data.
Open your style.css file. We will use CSS to center the forms on the screen. We will also add padding, borders, and clear visual cues for the input fields. We want the form to be highly readable with large text and plenty of white space.
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f4f7f6;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.container {
background-color: #ffffff;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
h1, h2 {
text-align: center;
color: #333333;
}
input {
display: block;
width: 100%;
padding: 12px;
margin: 15px 0;
border: 1px solid #cccccc;
border-radius: 4px;
box-sizing: border-box;
font-size: 16px;
}
button {
width: 100%;
padding: 12px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s ease;
}
button:hover {
background-color: #0056b3;
}
The CSS uses Flexbox (display: flex) on the body to center the container perfectly in the middle of the browser window. The height: 100vh ensures the body takes up exactly 100% of the viewport height, allowing the centering to work vertically as well as horizontally.
The box-sizing: border-box rule ensures that padding does not stretch the input fields beyond the 400-pixel maximum width. Without this rule, a 100% wide input with 12 pixels of padding would overflow the container and cause horizontal scrolling.
We set the font size to 16 pixels. This is a critical accessibility choice. Browsers on mobile devices (like Safari on iOS) automatically zoom in on input fields if the font size is smaller than 16px. By explicitly setting the font to 16px, you prevent the jarring auto-zoom effect, keeping your user interface stable when users tap the email field.
The button has a :hover pseudo-class that changes the background color over 0.3 seconds. This smooth transition gives the button a polished feel. The blue color (#007bff) is a standard web convention for primary actions. Never make your primary button a light gray color, as users will assume it is disabled.
Step 4: Add JavaScript for Frontend Logic
JavaScript makes your website interactive. It handles button clicks, communicates with the server, and updates the screen without requiring a page refresh. This is known as building a Single Page Application (SPA).
Open script.js. We need to write logic that listens for form submissions. When a user clicks “Sign Up”, JavaScript must grab the email and password, package them into a data object, and send them to our backend server.
We will use the Fetch API. It is a modern, built-in browser feature used for making HTTP requests. Fetch takes two arguments: the URL endpoint and an options object containing the method, headers, and body.
// Elements
const signupForm = document.getElementById('signupForm');
const loginForm = document.getElementById('loginForm');
const dashboard = document.getElementById('dashboard');
const userEmailSpan = document.getElementById('userEmail');
// Switch between forms
document.getElementById('showLogin').addEventListener('click', () => {
signupForm.style.display = 'none';
loginForm.style.display = 'block';
});
document.getElementById('showSignup').addEventListener('click', () => {
loginForm.style.display = 'none';
signupForm.style.display = 'block';
});
// Handle Signup
signupForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('signupEmail').value;
const password = document.getElementById('signupPassword').value;
const response = await fetch('http://localhost:3000/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
alert('Signup successful! Please log in.');
signupForm.style.display = 'none';
loginForm.style.display = 'block';
} else {
alert(data.message || 'Signup failed.');
}
});
The e.preventDefault() function is critical. Without it, clicking “Sign Up” would cause the browser to refresh the page, stopping your JavaScript from running. We want to handle the submission quietly in the background. We capture the event object e and tell it to prevent its default behavior.
The async/await syntax ensures the code waits for the server to respond before moving to the next line. Fetching data from a server takes time. If you did not use await, the code would try to read the server’s response before the server even finished processing the password.
The JSON.stringify({ email, password }) command takes your JavaScript object and converts it into a plain text string formatted as JSON. JSON looks like this: {"email":"test@test.com","password":"12345678"}. All server communication on the modern web uses this format because it is lightweight and easy for machines to parse.
We check if (response.ok) to see if the server returned a status code in the 200 range. If the server returns a 400 (Bad Request) or 500 (Internal Server Error) code, response.ok will be false. We then display the specific error message sent back from the server, like “User already exists.”
Step 5: Set Up a Backend with Node.js
Right now, our Fetch API has nowhere to send the data. We need a backend server to receive the user’s email and password, verify them, and save them. Node.js and Express.js are perfect for this job.
Open your terminal inside VS Code. Ensure you are in the user-account-site folder. Type npm init -y and press Enter. This creates a package.json file with default settings in about 2 seconds. This file tracks all the external libraries your server needs to run.
Next, install the necessary packages by typing: npm install express cors jsonwebtoken bcryptjs. Press Enter. This command downloads four specific tools from the npm registry. It usually takes about 15 seconds to complete.
Express builds the server routes. CORS (Cross-Origin Resource Sharing) allows your frontend to talk to your backend without security warnings. JSON Web Token (JWT) creates secure, temporary access tokens. Bcryptjs encrypts passwords so hackers cannot read them.
Create a new file named server.js. This file holds all your server logic. We will start by setting up the basic server and the user registration route.
const express = require('express');
const cors = require('cors');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const app = express();
app.use(cors());
app.use(express.json());
// A temporary array to store users. In a real app, use a database.
const users = [];
const SECRET_KEY = 'your_super_secret_key_change_this';
app.post('/api/signup', async (req, res) => {
try {
const { email, password } = req.body;
// Check if user already exists
const userExists = users.find(u => u.email === email);
if (userExists) {
return res.status(400).json({ message: 'User already exists' });
}
// Hash the password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// Save user
const newUser = { email, password: hashedPassword };
users.push(newUser);
res.status(201).json({ message: 'User created successfully' });
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
To start your server, go to your terminal and type node server.js. You should see the message “Server running on http://localhost:3000”. Leave this terminal window open. If you close it, the server shuts down.
The app.use(cors()) line is essential for local development. Right now, your frontend lives on a Live Server URL (like http://127.0.0.1:5500), and your backend lives on http://localhost:3000. Browsers have a strict security rule that blocks websites from talking to different ports. CORS tells the browser, “I trust this specific frontend, let it talk to me.”
The app.use(express.json()) line tells Express to intercept incoming data and parse it from a JSON string back into a JavaScript object. Without this line, req.body would be completely empty, and your server would never receive the email or password.
We use a temporary array const users = [] to hold our data. When you restart the server, this array empties. This is fine for testing. In a production environment with thousands of users, you would replace this users.push() line with a database insert command, which we will cover later.
Step 6: Secure User Passwords and Manage Sessions
Security is not optional when handling user accounts. Data breaches exposed over 22 billion records in 2021 alone. You must protect your users by implementing standard security protocols. The most basic requirement is never storing plain text passwords.
Notice the bcrypt.genSalt(10) and bcrypt.hash(password, salt) lines in the server code above. These lines take the plain text password (like “password123”) and scramble it into an unreadable string.
The “10” represents the cost factor. It means the algorithm runs its internal math 2 to the power of 10 (1,024) times. It takes the server roughly 100 milliseconds to process this math. This is fast enough that a real user logging in won’t notice the delay. However, if a hacker tries to guess millions of passwords, that 100 milliseconds per guess adds up to 27 hours for just a million attempts.
If a hacker breaks into your database, they only see the scrambled hash (e.g., $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxep68lN4EtLcifFm). They cannot reverse-engineer it back to the original password because the hashing process is strictly one-way. The only way to crack it is to guess passwords, hash them, and see if the output matches.
You also need to implement JSON Web Tokens (JWT) for the login route. When a user successfully logs in, the server generates a unique token. The browser saves this token and sends it along with every future request to prove the user is logged in.
Add this login route to your server.js file, right above the app.listen command:
app.post('/api/login', async (req, res) => {
try {
const { email, password } = req.body;
// Find user
const user = users.find(u => u.email === email);
if (!user) {
return res.status(400).json({ message: 'Invalid credentials' });
}
// Check password
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(400).json({ message: 'Invalid credentials' });
}
// Generate Token (Expires in 1 hour)
const token = jwt.sign({ email: user.email }, SECRET_KEY, { expiresIn: '1h' });
res.json({ token });
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
});
Notice the expiresIn: '1h' option. This forces the user to log in again after 60 minutes of inactivity. It limits the damage if a hacker manages to steal an active token. The hacker only has a 60-minute window to cause trouble before the token turns into useless text.
The jwt.sign function takes three arguments. First is the payload, which is the data you want to store inside the token (in this case, the user’s email). Second is the secret key, which is a long string of random characters only you know. Third are the options, like the expiration date.
Notice we return “Invalid credentials” whether the email is wrong or the password is wrong. You should never tell a user “Email not found” or “Password is incorrect” specifically. If you do, a hacker can use the login form to figure out which emails exist in your database. Generic error messages stop hackers from mapping out your user base.
Update your script.js file to handle the login response. Save the token to the browser’s local storage so the user stays logged in as they navigate the website.
// Handle Login
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('loginEmail').value;
const password = document.getElementById('loginPassword').value;
const response = await fetch('http://localhost:3000/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
localStorage.setItem('token', data.token);
showDashboard(email);
} else {
alert(data.message || 'Login failed.');
}
});
function showDashboard(email) {
loginForm.style.display = 'none';
signupForm.style.display = 'none';
dashboard.style.display = 'block';
userEmailSpan.textContent = email;
}
// Handle Logout
document.getElementById('logoutBtn').addEventListener('click', () => {
localStorage.removeItem('token');
dashboard.style.display = 'none';
signupForm.style.display = 'block';
});
We use localStorage.setItem('token', data.token) to save the JWT directly inside the user’s browser. Local storage is a built-in database in every browser that can hold about 5 megabytes of data per website. When the user clicks “Log Out”, we use localStorage.removeItem('token') to delete the passcode. The server does not need to be notified; the user simply forgets their passcode and is effectively logged out.
Connecting to a Real PostgreSQL Database
Up to this point, we have been storing user data in a temporary JavaScript array called const users = []. This is great for testing. When you restart your Node.js server, that array empties out completely. Every user loses their account. To build a real website, you need a persistent database.
PostgreSQL is one of the most reliable databases available today. It processes standard reads in under 0.1 milliseconds. It handles massive datasets easily. Best of all, it is completely free to use locally.
To connect Node.js to PostgreSQL, we use a library called pg. Stop your server by pressing Ctrl + C in your terminal. Install the library by running this command: npm install pg.
You also need the database running on your computer. Download PostgreSQL from the official website. The installation takes about 5 minutes. Set a password for the default “postgres” user during setup. Write this password down. You will need it in exactly 30 seconds.
Once installed, open a tool called pgAdmin. This is a visual interface for your database. It looks a bit like a file explorer. Right-click on “Databases”, select “Create”, and name your new database user_auth_db.
Now, we need to update our server.js file. We will replace the temporary array with real database queries.
const { Pool } = require('pg');
const pool = new Pool({
user: 'postgres',
host: 'localhost',
database: 'user_auth_db',
password: 'your_postgres_password',
port: 5432,
});
Add this code near the top of your server.js file. Change 'your_postgres_password' to the actual password you created during installation. The Pool object manages connections for you. Instead of opening and closing a connection for every single user, it keeps a small
Further Reading
- How to Build a Website for Your Business for Free
- Purchase a Domain on Godaddy or Namecheap
Start Here
Tools and Calculators
Frequently Asked Questions
How do websites keep users logged in across different pages?
What is the difference between server-side sessions and JSON Web Tokens (JWT)?
What technologies are needed to build a custom authentication system?
Can I add user accounts to my website without coding a backend?
Next step
Recommended Web Hosting
The Best Web Hosting - Free Domain for 1st Year, Free SSL Certificate, 1-Click WordPress Install, Expert 24/7 Support. Starting at CA$2.99/mo* (Regularly CA$8.49/mo). Recommended by WordPress.org, Trusted by over 5 Million WordPress Users.
