Build a Full-Stack Contact Manager

Build a contact manager app with a Node.js backend, REST API, and SQLite database.

Introduction

30 Second Summary

Every web app you use daily follows the same pattern. A screen you interact with, a server that processes your actions, and a database that remembers everything.

In this project, you will build a Contact Manager that connects all three layers together. You will type a name and email into a form, watch it travel through an Express server, and land in a SQLite database that keeps it permanently.

What You'll Build

Picture this: you add a contact, close your browser, restart your server, come back the next day, and your data is still there because it lives in a real database you created.

By the end of this project, you'll have:

  • A working form where you add contacts and see them appear instantly in a styled list, then edit or delete them with a click.
  • A REST API you built yourself that accepts valid data, rejects bad data with clear error messages, and responds with the right HTTP status codes.
  • A database that survives server restarts, proving your contacts are stored permanently on disk.
  • Secret Mission: A live search bar that filters contacts in real-time as you type, powered by SQL queries on the backend.

Are there any prerequisites?

Basic familiarity with HTML, CSS, and JavaScript is helpful. Rusty is completely fine. This project explains every concept as you go.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

You're building a Contact Manager to understand how a user's click in a browser becomes data in a database and how that data comes back to the screen. This is the skeleton behind every web application.

Set Up Your Project

Your Contact Manager needs two key ingredients before you can write any code. It needs Express, a framework that turns Node.js into a web server, and better-sqlite3, a library that gives you a database with zero setup.

In this step, you will create a project folder, install both packages, and set up the file structure so everything is ready to build on.

In this step, get ready to:
  • Create a project folder and initialize it with npm.
  • Install Express and better-sqlite3.
  • Create the file structure for your server and frontend.
Open a terminal and create your project folder

Every command you run in this project happens inside the VS Code terminal. This keeps your file explorer and code editor in one window.

  • In VS Code, open a new terminal by clicking Terminal in the top menu bar, then New Terminal.

Keyboard shortcut

You can also press Ctrl+` (backtick key, next to the 1 key) on both macOS and Windows to toggle the terminal panel.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and initialize it by running these commands:
mkdir contact-manager
cd contact-manager
npm init -y

You should see output showing a generated package.json file with your project name, version, and other defaults.

What does this do?

The mkdir command creates a new folder. The cd command moves you inside it.

The npm init -y command creates a package.json file that tracks your project's dependencies. The -y flag accepts all default values so you skip the interactive prompts.

Seeing 'command not found' for npm?

This means Node.js is not installed or not in your system PATH. Confirm Node.js is installed by running node --version. You should see a version number.

If nothing appears, install Node.js from nodejs.org before continuing.

help me fix npm not being recognized in my terminal

Install Express and better-sqlite3

Express turns Node.js into a web server that can receive and respond to browser requests. better-sqlite3 gives you a SQLite database that stores data in a single file with no external server required.

  • Install both packages by running this command:
npm install express@5.2.1 better-sqlite3@13.0.3

You should see output confirming packages were added. This may take a moment as better-sqlite3 downloads prebuilt native binaries for your system.

Why pin specific versions?

The @5.2.1 and @13.0.3 pins lock you to exact versions so your project behaves identically to this guide. Without version pins, npm grabs the latest release, which might introduce breaking changes.

Install failing with build errors?

The better-sqlite3 package includes prebuilt binaries for most systems. If you see compilation errors, make sure you are running Node.js 18 or higher by checking node --version.

help me fix better-sqlite3 install errors

Create the project file structure

Your project needs a server file and a public folder for the frontend files that the browser will load.

  • Create the folder and empty files by running these commands:

macOS

mkdir public
touch server.js public/index.html public/style.css public/app.js

Windows

mkdir public
type nul > server.js
type nul > public\index.html
type nul > public\style.css
type nul > public\app.js

What is this file structure?

  • server.js is where your backend server code will live.
  • public/index.html is the HTML page your browser loads.
  • public/style.css holds the visual styling for that page.
  • public/app.js holds the frontend JavaScript that talks to your server.
  • Confirm the files exist by running:
ls public

You should see app.js, index.html, and style.css listed in the output.

Before you run the final check, what two packages do you expect to see listed?

  • Verify your installed packages by running:
npm list

You should see express@5.2.1 and better-sqlite3@13.0.3 listed in the output.

Packages not showing up?

Make sure you are inside the contact-manager folder. Run pwd (macOS/Linux) or cd (Windows) to check your current location. If you are in the wrong folder, run cd ~/Desktop/contact-manager and try again.

help me find my installed npm packages

Your project skeleton is ready. Next up, you will write your first server and see it respond to a browser request.

Build Your First Server

Your project folder is set up with Express and better-sqlite3 ready to go. Now it is time to write actual server code that responds when a browser makes a request.

Without a running server, your browser has nothing to talk to. Every web application follows the same pattern: the browser sends a request, the server sends back a response.

In this step, get ready to:
  • Write a minimal Express server in server.js.
  • Start the server and confirm it returns data to your browser.
Write a minimal Express server

Express is a framework that turns Node.js into a web server. You define routes (URL paths) and tell Express what data to send back when someone visits them.

  • Open server.js in VS Code and paste the following code:
const express = require('express');

const app = express();
const PORT = 3000;

app.get('/api/contacts', (req, res) => {
  res.json([
    { id: 1, name: 'Alice Johnson', email: 'alice@example.com', phone: '555-0101' }
  ]);
});

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

What does this code do?

  • require('express') loads the Express library you installed earlier.
  • app.get('/api/contacts', ...) defines a route. When a browser visits /api/contacts, Express runs the function inside.
  • res.json([...]) sends back a JSON array as the response. This is how servers send structured data to browsers.
  • app.listen(PORT, ...) starts the server and keeps it running, waiting for requests on port 3000.
  • Save server.js.
Start the server and test it in your browser

Your server code is written but not running yet. You need to start it with Node.js so it begins listening for requests.

  • Start the server by running this command in your terminal:
node server.js

You should see this output in your terminal:

Server running at http://localhost:3000

What does this mean?

Your server is now running and waiting for requests. The terminal is occupied by the server process, so you cannot type other commands in it until you stop the server.

Before you open the URL, what do you think your browser will display when it visits /api/contacts?

  • Open your browser (Chrome or Edge).
  • Type http://localhost:3000/api/contacts in the address bar and press Enter.

You should see the JSON array with Alice's contact displayed on screen. This is your server responding to an HTTP GET request.

What just happened?

Every time you type a URL into the browser address bar, that is a GET request. Your browser asked your server for /api/contacts, and the server ran the function you defined and sent back JSON data.

This is the same request-response cycle that powers every web application you use daily.

  • Return to your terminal in VS Code.
  • Press Ctrl+C to stop the server.

Don't see JSON in the browser?

  • Make sure the terminal still shows "Server running at http://localhost:3000". If you see an error, check server.js for typos (missing commas, brackets, or parentheses).
  • Double-check the URL is exactly http://localhost:3000/api/contacts. A typo in the path returns a 404 error.
  • If you see "address already in use", another process is using port 3000. Close other terminals or restart VS Code.

Still stuck? Help me debug why my Express server isn't returning JSON.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete server.js at this point:

const express = require('express');

const app = express();
const PORT = 3000;

app.get('/api/contacts', (req, res) => {
  res.json([
    { id: 1, name: 'Alice Johnson', email: 'alice@example.com', phone: '555-0101' }
  ]);
});

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

Your server is alive and responding to requests. Next up, you will connect a real database so contacts are stored permanently instead of being hardcoded.

Connect the Database and Build Your API

In the previous step, you proved your server can respond to a browser request with JSON. The problem is that Alice Johnson is hardcoded directly into your code.

The moment you change that code, Alice disappears. A real application stores data in a database so it survives server restarts. In this step, you will connect a SQLite database to your server and build a complete REST API with endpoints for creating, reading, updating, and deleting contacts.

In this step, get ready to:
  • Connect a SQLite database to your Express server.
  • Create a contacts table that stores names, emails, and phone numbers.
  • Build API routes for all four CRUD operations (Create, Read, Update, Delete).
Replace the hardcoded data with a real database

Right now your server returns a hardcoded array. You are going to replace the entire contents of server.js with new code that opens a real database file and reads from it instead.

  • In VS Code, open server.js.
  • Select all the existing code (Cmd+A on macOS or Ctrl+A on Windows) and delete it.
  • Paste the following setup code that imports Express, connects to a SQLite database, and creates the contacts table:
// Import Express and the SQLite library
const express = require('express');
const Database = require('better-sqlite3');

// Create the Express app
const app = express();
const PORT = 3000;

// Parse incoming JSON and serve files from the public folder
app.use(express.json());
app.use(express.static('public'));

// Open (or create) the database file
const db = new Database('contacts.db');
db.pragma('journal_mode = WAL');

What does this code do?

  • require('better-sqlite3') loads the SQLite library you installed in Step 1. It gives you a database without needing a separate database server.
  • express.json() is middleware that reads JSON from incoming requests so your routes can access req.body.
  • express.static('public') tells Express to serve any file in the public folder directly to the browser. This is how your HTML, CSS, and JS will be delivered later.
  • new Database('contacts.db') opens (or creates) a database file called contacts.db in your project folder.
  • db.pragma('journal_mode = WAL') enables a performance optimization for how the database writes to disk.
  • Directly below the code you just wrote, add the table creation, the GET routes, and the server start:
// Create the contacts table if it doesn't exist yet
db.exec(`
  CREATE TABLE IF NOT EXISTS contacts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    phone TEXT,
    created_at TEXT DEFAULT (datetime('now'))
  )
`);

// Return all contacts, newest first
app.get('/api/contacts', (req, res) => {
  const contacts = db.prepare('SELECT * FROM contacts ORDER BY created_at DESC').all();
  res.json(contacts);
});

// Return a single contact by ID, or 404 if not found
app.get('/api/contacts/:id', (req, res) => {
  const contact = db.prepare('SELECT * FROM contacts WHERE id = ?').get(req.params.id);
  if (!contact) {
    return res.status(404).json({ error: 'Contact not found' });
  }
  res.json(contact);
});

// Start the server
app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

What do the GET routes do?

db.exec() runs raw SQL to create the contacts table. The IF NOT EXISTS clause means it only creates the table the first time. After that, it skips this step.

The first GET route uses .all() to fetch every row from the table as an array. The second GET route uses .get() to fetch a single row by its id. If no row matches, it returns a 404 status code.

Test the GET endpoint
  • Save server.js.
  • Start the server by running this command in your terminal:
node server.js

Before you open the browser, think about this: your old server returned Alice Johnson's data. What do you expect to see now that the data comes from a brand new, empty database?

  • Open your browser and visit http://localhost:3000/api/contacts.

You see an empty array []. The database exists and works, but there are no contacts in it yet.

  • Back in your terminal, confirm the database file was created by running:
ls

You should see contacts.db listed alongside server.js, package.json, and the public folder.

Seeing an error instead of the empty array?

  • If you see Cannot find module 'better-sqlite3', make sure you ran npm install in the contact-manager folder, not a parent directory.
  • If you see a syntax error, double-check the backticks around the CREATE TABLE SQL. They are template literal backticks (the key to the left of 1), not single quotes.
  • If the terminal says the port is already in use, you may have the old server still running. Press Ctrl+C in any other open terminals, then try node server.js again.

Still stuck? Help me debug my server.js database connection

Add routes for creating, updating, and deleting contacts

Your GET routes let the browser read contacts. You also need POST (create), PUT (update), and DELETE routes so a frontend can modify data.

  • Stop the server by pressing Ctrl+C in your terminal.
  • In server.js, find the line that says // Start the server. Add the following code directly above it:
// Create a new contact
app.post('/api/contacts', (req, res) => {
  const { name, email, phone } = req.body;
  const result = db.prepare('INSERT INTO contacts (name, email, phone) VALUES (?, ?, ?)').run(name, email, phone || null);
  const newContact = db.prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid);
  res.status(201).json(newContact);
});

// Update an existing contact
app.put('/api/contacts/:id', (req, res) => {
  const { name, email, phone } = req.body;
  const existing = db.prepare('SELECT * FROM contacts WHERE id = ?').get(req.params.id);
  if (!existing) {
    return res.status(404).json({ error: 'Contact not found' });
  }
  db.prepare('UPDATE contacts SET name = ?, email = ?, phone = ? WHERE id = ?').run(name, email, phone || null, req.params.id);
  const updated = db.prepare('SELECT * FROM contacts WHERE id = ?').get(req.params.id);
  res.json(updated);
});

// Delete a contact
app.delete('/api/contacts/:id', (req, res) => {
  const existing = db.prepare('SELECT * FROM contacts WHERE id = ?').get(req.params.id);
  if (!existing) {
    return res.status(404).json({ error: 'Contact not found' });
  }
  db.prepare('DELETE FROM contacts WHERE id = ?').run(req.params.id);
  res.json({ message: 'Contact deleted' });
});

What do the write routes do?

  • POST reads name, email, and phone from the request body, inserts a new row, then returns the newly created contact with a 201 status.
  • PUT first checks if the contact exists. If it does not, it returns a 404. Otherwise it updates the row and returns the updated data.
  • DELETE also checks existence first, then removes the row permanently.
  • The ? placeholders in the SQL prevent injection attacks by separating the query structure from user-provided values.
  • Save server.js.
  • Restart the server by running:
node server.js
  • Visit http://localhost:3000/api/contacts in your browser again.

You still see []. The database persists between restarts. There are no contacts yet because the browser address bar only sends GET requests. You need a frontend with a form to send POST requests.

Why can't I add contacts from the browser?

When you type a URL and press Enter, your browser always sends a GET request. POST, PUT, and DELETE require JavaScript code (like the fetch API) or a tool like Postman to send. In the next step, you will build a frontend that sends these requests for you.

Server crashes on restart?

  • Check for missing commas or closing parentheses in your new routes. Each route handler must end with });.
  • Make sure the app.listen block is still at the very bottom of the file, after all your route definitions.

Need help? Help me fix a crash in my server.js after adding POST, PUT, and DELETE routes

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and the server is running without errors in your terminal.

ⓧ I'd like to double check the full code

Your server.js should contain these sections in order. Scroll up and confirm each one is present:

  • The imports and setup section (require('express'), require('better-sqlite3'), app creation, middleware, database connection with WAL pragma).
  • The table creation section (db.exec(...) with the CREATE TABLE IF NOT EXISTS statement).
  • Two GET routes (/api/contacts returning all contacts, and /api/contacts/:id returning one).
  • A POST route for /api/contacts that inserts a new contact.
  • A PUT route for /api/contacts/:id that updates a contact.
  • A DELETE route for /api/contacts/:id that removes a contact.
  • The app.listen block at the very end.

Your API is fully built. It can create, read, update, and delete contacts. Next, you will build the frontend so you can actually use these endpoints from a real interface.

Build the Frontend

Your API is fully functional, but the only way to interact with it is by typing URLs into the browser address bar. That only sends GET requests.

A real user needs a form to create contacts and buttons to edit and delete them. In this step, you'll build a visual interface that sends POST, PUT, and DELETE requests to your backend using JavaScript's fetch function.

In this step, get ready to:
  • Build the HTML page with a contact form and list container.
  • Style the form, buttons, and contact cards with CSS.
  • Write JavaScript that sends requests to your API and updates the page.
Create the HTML structure

Your server already serves files from the public folder thanks to express.static. Any HTML file you place there is available at http://localhost:3000.

  • Open public/index.html in VS Code and add this HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Contact Manager</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <h1>Contact Manager</h1>
    
    <form id="contact-form">
      <input type="hidden" id="contact-id">
      <div id="error-message" class="error hidden"></div>
      <input type="text" id="name" placeholder="Name">
      <input type="text" id="email" placeholder="Email">
      <input type="tel" id="phone" placeholder="Phone (optional)">
      <div class="form-buttons">
        <button type="submit" id="submit-btn">Add Contact</button>
        <button type="button" id="cancel-btn" class="hidden">Cancel</button>
      </div>
    </form>

    <div id="contacts-list"></div>
  </div>

  <script src="app.js"></script>
</body>
</html>

What does this HTML do?

  • The <form> contains inputs for name, email, and phone, plus a hidden input that tracks which contact is being edited.
  • The error-message div will display validation errors (hidden by default).
  • The contacts-list div is empty for now. JavaScript will fill it with contact cards.
  • Save public/index.html.
  • Start your server by running this in your terminal:
node server.js
  • Open your browser and visit http://localhost:3000.

You should see a plain, unstyled form with input fields and a button. It looks rough, but the structure is there.

Seeing a blank page?

  • Make sure you saved the file as index.html inside the public folder, not in the project root.
  • Check that your server is running. If the terminal shows an error, stop it with Ctrl+C and restart with node server.js.
  • help me debug my blank page at localhost:3000
Add the CSS styles

The raw HTML works but looks terrible. CSS will give the form a clean card layout with proper spacing and button colors.

  • Open public/style.css and add these page layout styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #f5f5f5;
  padding: 2rem;
}

.container {
  max-width: 600px;
  margin: 0 auto;
}

h1 {
  margin-bottom: 1.5rem;
  color: #333;
}

form {
  background: white;
  padding: 1.5rem;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  margin-bottom: 2rem;
}
  • Below that, add the input and button base styles:
input {
  width: 100%;
  padding: 0.75rem;
  margin-bottom: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

input:focus {
  outline: none;
  border-color: #4a90d9;
}

.form-buttons {
  display: flex;
  gap: 0.5rem;
}

button {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
}
  • Save style.css and refresh your browser.

The form now has a white card with a shadow, properly spaced inputs, and a rounded button.

  • Continue adding the button color and utility styles below what you already wrote:
#submit-btn {
  background: #4a90d9;
  color: white;
}

#submit-btn:hover {
  background: #357abd;
}

#cancel-btn {
  background: #999;
  color: white;
}

#cancel-btn:hover {
  background: #777;
}

.hidden {
  display: none;
}

.error {
  background: #ffeaea;
  color: #d32f2f;
  padding: 0.75rem;
  border-radius: 4px;
  margin-bottom: 0.75rem;
  font-size: 0.9rem;
}
  • Save and refresh. The Add Contact button is now blue.
  • Now add the contact card layout styles:
.contact-card {
  background: white;
  padding: 1rem 1.5rem;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  margin-bottom: 0.75rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.contact-info h3 {
  color: #333;
  margin-bottom: 0.25rem;
}

.contact-info p {
  color: #666;
  font-size: 0.9rem;
}

.contact-actions {
  display: flex;
  gap: 0.5rem;
}

.contact-actions button {
  padding: 0.5rem 0.75rem;
  font-size: 0.85rem;
}
  • Finally, add the card button colors and empty state:
.edit-btn {
  background: #f0ad4e;
  color: white;
}

.edit-btn:hover {
  background: #ec971f;
}

.delete-btn {
  background: #d9534f;
  color: white;
}

.delete-btn:hover {
  background: #c9302c;
}

.empty-state {
  color: #999;
  text-align: center;
  padding: 2rem;
}
  • Save style.css. These card styles take effect once JavaScript adds contacts to the page.
Connect the frontend to your API with JavaScript

The fetch API lets your browser send HTTP requests to the server without reloading the page. Each function you write here maps to one of your API endpoints.

  • Open public/app.js and add the DOM references and contact loading logic:
// API endpoint base URL
const API_URL = '/api/contacts';

// Grab references to all the form elements
const form = document.getElementById('contact-form');
const contactIdInput = document.getElementById('contact-id');
const nameInput = document.getElementById('name');
const emailInput = document.getElementById('email');
const phoneInput = document.getElementById('phone');
const submitBtn = document.getElementById('submit-btn');
const cancelBtn = document.getElementById('cancel-btn');
const contactsList = document.getElementById('contacts-list');
const errorMessage = document.getElementById('error-message');

// Load contacts as soon as the page opens
loadContacts();

async function loadContacts() {
  const response = await fetch(API_URL);
  const contacts = await response.json();
  renderContacts(contacts);
}
  • Below that, add the function that turns contact data into HTML cards:
function renderContacts(contacts) {
  if (contacts.length === 0) {
    contactsList.innerHTML = '<p class="empty-state">No contacts yet. Add one above!</p>';
    return;
  }

  contactsList.innerHTML = contacts.map(contact => `
    <div class="contact-card">
      <div class="contact-info">
        <h3>${contact.name}</h3>
        <p>${contact.email}${contact.phone ? ' | ' + contact.phone : ''}</p>
      </div>
      <div class="contact-actions">
        <button class="edit-btn" onclick="editContact(${contact.id})">Edit</button>
        <button class="delete-btn" onclick="deleteContact(${contact.id})">Delete</button>
      </div>
    </div>
  `).join('');
}
  • Save app.js and refresh your browser.

You should see the message "No contacts yet. Add one above!" styled in grey and centered below the form. This proves your JavaScript loaded, called the API, and rendered the empty-state result.

What is happening here?

When the page loads, loadContacts() sends a GET request to /api/contacts. The server returns an empty array.

Then renderContacts() checks the array length. Since it's zero, it displays the empty-state message instead of building cards.

Not seeing the empty-state message?

  • Open DevTools with F12 and check the Console tab for red errors. A typo in an element ID will cause a null reference error.
  • Confirm the <script src="app.js"></script> tag is at the bottom of index.html, before </body>.
  • help me debug why my app.js isn't working
  • Now add the form submit handler that sends contact data to your API:
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  hideError();
  const contactData = {
    name: nameInput.value,
    email: emailInput.value,
    phone: phoneInput.value
  };
  const id = contactIdInput.value;
  let response;
  if (id) {
    // Editing an existing contact - send PUT
    response = await fetch(`${API_URL}/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(contactData)
    });
  } else {
    // Creating a new contact - send POST
    response = await fetch(API_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(contactData)
    });
  }
  if (!response.ok) {
    const data = await response.json();
    showError(data.error);
    return;
  }
  resetForm();
  loadContacts();
});

What does this form handler do?

  • It checks contactIdInput.value to decide if this is a new contact (POST) or an update to an existing one (PUT).
  • It packages the form data as JSON and sends it to the server with the appropriate HTTP method.
  • After a successful response, it resets the form and reloads the contact list so the new entry appears immediately.
  • Save app.js and refresh your browser.
  • Type Jane Doe in the Name field and jane@email.com in the Email field.
  • Click Add Contact.

The contact appears in a styled card below the form. Your frontend just sent a POST request to the server, the server stored it in SQLite, and the page refreshed the list.

  • Now add the edit, delete, and helper functions below the form handler:
async function editContact(id) {
  const response = await fetch(`${API_URL}/${id}`);
  const contact = await response.json();

  contactIdInput.value = contact.id;
  nameInput.value = contact.name;
  emailInput.value = contact.email;
  phoneInput.value = contact.phone || '';

  submitBtn.textContent = 'Update Contact';
  cancelBtn.classList.remove('hidden');
}

async function deleteContact(id) {
  if (!confirm('Are you sure you want to delete this contact?')) return;

  await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
  loadContacts();
}
  • Finally, add the cancel button listener and utility functions:
cancelBtn.addEventListener('click', resetForm);

function resetForm() {
  contactIdInput.value = '';
  form.reset();
  submitBtn.textContent = 'Add Contact';
  cancelBtn.classList.add('hidden');
  hideError();
}

function showError(message) {
  errorMessage.textContent = message;
  errorMessage.classList.remove('hidden');
}

function hideError() {
  errorMessage.textContent = '';
  errorMessage.classList.add('hidden');
}

What do these functions do?

  • editContact() fetches the contact's data, fills it into the form, and changes the button text to "Update Contact".
  • deleteContact() asks for confirmation, then sends a DELETE request to remove the contact.
  • resetForm() clears the form and puts it back in "add" mode after an edit or cancel.
  • Save app.js and refresh your browser.
  • Click Edit on the Jane Doe contact. The form fills with her data and the button changes to Update Contact.
  • Change the email to jane@newmail.com and click Update Contact. The card updates.
  • Click Delete and confirm. The contact disappears.

Edit or delete buttons not working?

  • Open DevTools with F12 and check for errors in the Console. A missing function name or typo will show a "not defined" error.
  • Make sure editContact and deleteContact are declared as top-level functions (not inside another function).
  • help me fix my edit and delete buttons

Before you move on, try something. What do you think happens if you submit the form with empty Name and Email fields?

  • Clear both the Name and Email fields so they are completely empty.
  • Click Add Contact.

A contact with no name appears in the list, creating a broken-looking card. The server happily accepted garbage data because you never told it to check.

Why did the server accept empty data?

Your POST endpoint takes whatever the request body contains and inserts it directly into the database. It never checks whether name or email actually have values.

This is the exact gap you'll fix in the next step with server-side validation.

✔️ Awesome, I've got everything!

Great. Confirm you've saved all three files: public/index.html, public/style.css, and public/app.js.

ⓧ I'd like to double check the full code

Scroll up and confirm each section is present in your files, in order:

  • index.html: DOCTYPE, head with stylesheet link, body with container div holding the form (hidden input, error div, name/email/phone inputs, form-buttons), contacts-list div, and script tag.
  • style.css: global reset, body, .container, h1, form, input, input:focus, .form-buttons, button, #submit-btn, #cancel-btn, .hidden, .error, .contact-card, .contact-info, .contact-actions, .edit-btn, .delete-btn, .empty-state.
  • app.js: API_URL constant, 9 DOM references, loadContacts call, loadContacts function, renderContacts function, form submit listener, editContact function, deleteContact function, cancelBtn listener, resetForm, showError, hideError.

Your frontend, backend, and database are fully connected. Next up, you'll add validation so the server rejects bad data before it ever reaches the database.

Add Validation and Error Handling

In the last step you built a working frontend that creates, edits, and deletes contacts. You also discovered a problem: submitting the form with empty fields creates broken entries in the database.

Users can always bypass frontend checks (by disabling JavaScript or sending requests directly). A real application needs the server itself to reject invalid data. In this step, you will add validation to both the server routes and the HTML form so broken contacts can never be created again.

In this step, get ready to:
  • Add server-side validation to the POST and PUT routes.
  • Add browser-level validation to the HTML form inputs.
  • Clean up broken contacts and verify that invalid data is now rejected.
Add server-side validation to the POST and PUT routes

The server currently accepts whatever data arrives, even if name or email is empty. You need to add a check at the top of each route that rejects requests missing required fields.

  • In server.js, find the app.post('/api/contacts') route.
  • Find this line inside the POST route:
  const { name, email, phone } = req.body;
  • Add these lines directly below it:
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email are required' });
  }

What does this code do?

This check runs before anything touches the database. If name or email is empty (falsy), the server immediately responds with HTTP status 400 (Bad Request) and a JSON error message.

The return keyword stops the route handler. The database insert never runs, so no broken contact is created.

  • Now find the app.put('/api/contacts/:id') route further down in server.js.
  • Find the same const { name, email, phone } = req.body; line inside the PUT route.
  • Add the exact same validation check directly below it:
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email are required' });
  }
  • Save server.js.
  • Switch to your terminal and stop the running server with Ctrl+C.
  • Restart the server by running:
node server.js
  • Open your browser to http://localhost:3000.
  • Clear the Name and Email fields so they are empty, then click Add Contact.

You should see the red error message "Name and email are required" appear above the form. No broken contact is added to the list.

Not seeing the error message?

  • Make sure you saved server.js and restarted the server after saving. The old code is still running until you restart.
  • Check that the if (!name || !email) check is inside the app.post route, not outside it.
  • If the browser still submits without showing the error, your HTML might have required attributes already preventing submission. Remove them temporarily to test the server validation.

Why isn't my server-side validation rejecting empty data?

Why validate on the server if we can validate in the browser?

Browser validation (like HTML required attributes) is easy to bypass. Anyone can open DevTools, remove the attribute, and submit garbage. Or they can send requests directly using tools like Postman or curl.

Server-side validation is the only check you can truly trust, because the server is code YOU control. Browser validation is a convenience for the user. Server validation is the real gatekeeper.

Add browser-level validation to the form

The server now rejects bad data, but the user gets a better experience if the browser catches obvious mistakes before the request even leaves. HTML has built-in validation attributes for this.

  • In public/index.html, find the Name input:
      <input type="text" id="name" placeholder="Name">
  • Add required at the end, so the line looks like this:
      <input type="text" id="name" placeholder="Name" required>
  • Now find the Email input:
      <input type="text" id="email" placeholder="Email">
  • Change type="text" to type="email" and add required, so the line looks like this:
      <input type="email" id="email" placeholder="Email" required>

What do these attributes do?

  • required tells the browser to block form submission if the field is empty. The browser shows a tooltip like "Please fill out this field."
  • type="email" makes the browser check that the input looks like a valid email address (contains an @ symbol). It also brings up an email keyboard on mobile devices.
  • Save public/index.html.
  • Refresh your browser at http://localhost:3000.
  • Leave the Name field empty and click Add Contact.

The browser itself blocks the submission and shows a tooltip on the empty field. The form never sends a request to the server.

Browser not blocking the empty submission?

  • Make sure you typed required (no quotes, no equals sign) as an attribute on both inputs.
  • Check that you saved the file and did a full page refresh (not just a soft reload). Try Ctrl+Shift+R (Windows) or Cmd+Shift+R (macOS) to bypass the cache.

Why isn't my HTML required attribute preventing form submission?

✔️ Awesome, I've got everything!

Great. Double check you have saved both server.js and public/index.html.

ⓧ I'd like to double check the full code

Here is the full public/index.html with both changes applied:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Contact Manager</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <h1>Contact Manager</h1>
    
    <form id="contact-form">
      <input type="hidden" id="contact-id">
      <div id="error-message" class="error hidden"></div>
      <input type="text" id="name" placeholder="Name" required>
      <input type="email" id="email" placeholder="Email" required>
      <input type="tel" id="phone" placeholder="Phone (optional)">
      <div class="form-buttons">
        <button type="submit" id="submit-btn">Add Contact</button>
        <button type="button" id="cancel-btn" class="hidden">Cancel</button>
      </div>
    </form>

    <div id="contacts-list"></div>
  </div>

  <script src="app.js"></script>
</body>
</html>

For server.js, confirm these two things are present:

  • The app.post route has if (!name || !email) returning status 400, placed after the destructuring line and before the db.prepare('INSERT...') line.
  • The app.put route has the same if (!name || !email) check, placed after the destructuring line and before the existing check.
Clean up broken contacts and run the final test

You may still have broken contacts in your database from the previous step. Time to clean those up and confirm the full validation flow works end to end.

  • In your browser at http://localhost:3000, look at your contacts list.
  • Click the Delete button on any contact that has an empty name or email (the broken ones from Step 4).
  • Confirm each deletion when prompted.

Now for the real test. Before you try this, predict what will happen: if you open DevTools and remove the required attribute from the Name input, then submit with empty fields, will a broken contact be created or not?

  • Press F12 to open DevTools.
  • In the Elements tab, find the <input type="text" id="name"...> element.
  • Double-click the required attribute and delete it.
  • Do the same for the email input.
  • Clear both fields and click Add Contact.

The red error message "Name and email are required" appears above the form. No broken contact is created. The server rejected the request even though the browser validation was bypassed. This is exactly why server-side validation matters.

Two layers of defense

You now have validation at two levels. The HTML required attribute gives users instant feedback without a network request. The server-side if (!name || !email) check is the real guard that cannot be bypassed from the client.

Every production web app follows this same pattern. Browser validation is a courtesy. Server validation is the law.

Your Contact Manager now properly rejects invalid data at both the browser and server level. You have a complete, working full-stack application with persistent storage, a REST API, and proper error handling. Next up: a Secret Mission to add real-time search filtering.

Secret mission

Add Search and Filter

Your contact manager works great with a handful of entries. But imagine hundreds of contacts. Can you add a search bar that filters the list in real-time as you type?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The contact-manager project folder on your Desktop (contains all source files).
  • The node_modules folder inside contact-manager (npm packages, stored locally).
  • The contacts.db file inside contact-manager (your SQLite database, stored locally).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with your Contact Manager.

  • Your server is still running. Visit http://localhost:3000 anytime to use your app.
  • If you stop the server, restart it by opening a terminal in your contact-manager folder and running node server.js.
  • Your contacts persist in contacts.db between restarts.

Pause - I'll come back to this later

Stop the running server to free up your terminal. All your files and data stay in place for when you return.

  • Switch to the terminal where your server is running.
  • Press Ctrl+C to stop the server.
  • Your project files and database remain on disk. Run node server.js from the contact-manager folder to pick up where you left off.

Delete - I don't want to use this again

Remove all project files, packages, and your database permanently. This cannot be undone.

  • Switch to the terminal where your server is running and press Ctrl+C to stop it.
  • Navigate to your Desktop and delete the entire project folder.

macOS

  • Delete the project folder by running:
cd ~/Desktop
rm -rf contact-manager
  • Confirm the folder is gone by running ls ~/Desktop. You should no longer see contact-manager listed.

Windows

  • Delete the project folder by running:
cd ~\Desktop
rmdir /s /q contact-manager
  • Confirm the folder is gone by running dir ~\Desktop. You should no longer see contact-manager listed.

This removes everything: your source code, the node_modules folder, and your contacts.db database.

Nice Work!

Nice Work!

You just built a full-stack web application from scratch. Your Contact Manager has a frontend, a backend, and a database all talking to each other on your local machine.

Here's what you learned:

  • Built a Node.js + Express web server that handles HTTP requests and serves both an API and static files from the same origin.
  • Created a SQLite database with a contacts table and built a complete REST API with GET, POST, PUT, and DELETE endpoints that persist data across server restarts.
  • Connected a plain HTML/CSS/JS frontend to your backend using the fetch API, and implemented server-side validation that rejects invalid data with proper HTTP status codes and error messages.
  • Secret Mission: Added real-time search filtering using query parameters and SQL LIKE queries to find contacts as you type.

Ready to quiz yourself?