Build a Habit Tracker with Node.js
Build a persistent Node.js habit tracker with a browser UI, JSON API, and tests.
Introduction
30 Second Summary
Your habit list only earns your trust when tomorrow's entries are still there after the app closes. A restart that wipes everything out turns useful progress into a temporary note.
In this project, you will build a Habit Tracker that sends entries from Microsoft Edge to a Node.js server. After seeing temporary data disappear on restart, you will save every habit in a JSON file.
What You'll Build
You will watch a new habit appear in the browser before proving that the same entry returns after a full server restart.
By the end of this project, you'll have:
- A working browser Habit Tracker where you can submit a habit and see it appear immediately.
- A persistent habit list that reloads from habits.json after the server restarts.
- An automated test report that confirms names are cleaned up, IDs advance, and blank input is rejected.
- Secret Mission: Add a persistent completion button backed by a PATCH route so habits stay marked Done after a restart.
Are there any prerequisites?
You should be comfortable with JavaScript variables, functions, arrays, objects, and async/await. You also need Windows, Visual Studio Code, Node.js 24.22.1 LTS, and Microsoft Edge.
Before We Start
Before the hands-on work begins, this is your moment to commit to the Habit Tracker you are about to build. You will define what it stores and why those entries need to survive a server restart.
Verify Your Windows Setup
The Habit Tracker runs locally. Every result depends on the tools installed on your Windows PC. A missing runtime or browser would block every visible result.
Node.js runs the server from the integrated terminal in Visual Studio Code. Microsoft Edge displays the browser page.
In this step, you will verify each tool before creating any project files.
In this step, get ready to:
- Prepare an empty habit-tracker workspace in Visual Studio Code.
- Verify Node.js v24.22.1 LTS from the integrated terminal.
- Confirm Microsoft Edge opens successfully.
Prepare the empty project folder
A Visual Studio Code workspace keeps the editor and terminal focused on one folder. Your future Habit Tracker files will all live inside habit-tracker.
- Press Windows+E to open File Explorer.
- Select Desktop in the left sidebar.
- Press Ctrl+Shift+N to create an empty folder.
- Type habit-tracker as the folder name.
- Press Enter to save the name.
You should see the empty habit-tracker folder on your Desktop.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search box.
- Press Enter to open Visual Studio Code.
- Select File from the top menu.
- Select Open Folder....
- Select the habit-tracker folder from your Desktop.
- Select Select Folder.
Visual Studio Code may show a Workspace Trust dialog because this folder is opening as a workspace.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
- Select View from the top menu.
- Select Terminal.
Your empty workspace is ready. You should see habit-tracker in the Explorer sidebar. The terminal prompt should show that it is inside the same folder.
Verify the Node.js runtime
The server needs the expected Node.js release so its built-in features behave consistently. The version command asks the installed runtime to identify itself.
- Check the Node.js version from the integrated terminal by running this command:
node --version
What does this command check?
- The node command starts the installed Node.js executable.
- The --version flag prints the release that Visual Studio Code can access.
✔️ I see the required LTS version
Good. Your integrated terminal prints v24.22.1. The runtime for your Habit Tracker is ready.
ⓧ I see an older version
The installed release does not match this project. Install the required LTS release before continuing.
- Visit the official Node.js download page in your browser.
- Download the Windows installer for Node.js v24.22.1 LTS.
- Run the downloaded Windows installer.
- Keep the default options on each setup screen.
- Close Visual Studio Code after the installation finishes.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search box.
- Press Enter to restart Visual Studio Code.
Visual Studio Code now starts with access to the updated Node.js installation.
- Confirm the Explorer sidebar still shows habit-tracker.
- Select File if habit-tracker is missing from the Explorer sidebar.
- Select Open Folder....
- Select the habit-tracker folder from your Desktop.
- Select Select Folder.
- Select View from the top menu.
- Select Terminal.
- Verify the new installation by running this command:
node --version
What confirms the upgrade?
- The terminal should now print v24.22.1.
- This output confirms Visual Studio Code can access the required LTS runtime.
ⓧ Node.js is unavailable
The terminal cannot access Node.js yet. Install the required LTS release before continuing.
- Visit the official Node.js download page in your browser.
- Download the Windows installer for Node.js v24.22.1 LTS.
- Run the downloaded Windows installer.
- Keep the default options on each setup screen.
- Close Visual Studio Code after the installation finishes.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search box.
- Press Enter to restart Visual Studio Code.
Visual Studio Code now starts with access to the new Node.js installation.
- Confirm the Explorer sidebar still shows habit-tracker.
- Select File if habit-tracker is missing from the Explorer sidebar.
- Select Open Folder....
- Select the habit-tracker folder from your Desktop.
- Select Select Folder.
- Select View from the top menu.
- Select Terminal.
- Verify the installation by running this command:
node --version
What confirms the installation?
- The terminal should print v24.22.1.
- This output confirms the Node.js executable is available inside Visual Studio Code.
Still not seeing the required version?
Close every Visual Studio Code window after the installer finishes. Restarting the editor refreshes the system path used by its terminal.
Confirm that you downloaded the installer for v24.22.1 LTS from the official Node.js page.
Need another pair of eyes? Help me troubleshoot why Visual Studio Code cannot access Node.js v24.22.1 after installation.
Confirm Microsoft Edge opens
The browser is where you will see the Habit Tracker respond to clicks and form submissions. This check confirms Microsoft Edge is available for that visible feedback loop.
- Press the Windows key to open Windows search.
- Type Microsoft Edge into the search box.
- Press Enter to open Microsoft Edge.
✔️ Microsoft Edge opens
That completes the browser check. Microsoft Edge is ready to display the Habit Tracker in the next step.
ⓧ Microsoft Edge is unavailable
Install Microsoft Edge before continuing so you can test the browser experience.
- Visit the official Microsoft Edge download page in another available browser.
- Download Microsoft Edge for Windows.
- Run the downloaded installer.
- Press the Windows key after installation finishes.
- Type Microsoft Edge into the search box.
- Press Enter to open Microsoft Edge.
You should see a responsive Microsoft Edge window after the installation completes.
Microsoft Edge still not opening?
Confirm the installer finished before trying Windows search again. Restart Windows if the installed app is still missing from the search results.
Need help with the install? Help me troubleshoot why Microsoft Edge does not open on my Windows PC.
- Return to Visual Studio Code.
- Confirm the Explorer sidebar shows the empty habit-tracker workspace.
- Select View from the top menu.
- Select Terminal.
Before the final check, what version do you expect the integrated terminal to print?
- Confirm the runtime one final time by running this command:
node --version
What should I see?
- The integrated terminal should print v24.22.1.
- This output proves the required Node.js LTS runtime is available inside your project workspace.
- Return to the Microsoft Edge window from the Windows taskbar.
You should see Microsoft Edge open successfully beside Visual Studio Code. Your editor, runtime, browser, project folder, and integrated terminal are now ready.
Your Windows development setup is ready. Next up, you will serve the first Habit Tracker page from Node.js and open it in Microsoft Edge.
Serve the Starter Habit Tracker
Your Windows setup is ready. The empty habit-tracker folder is waiting in Visual Studio Code.
A page served by Node.js proves that your browser can reach your local server. You will build that connection before adding API logic.
In this step, get ready to:
- Build the starter Habit Tracker interface.
- Serve the page from a local Node.js server.
- Confirm the page loads in Microsoft Edge.
Create the starter browser page
The browser needs an HTML page with a form for new habits. It also needs dedicated areas for status messages and the habit list.
- Select the new-file icon beside habit-tracker in the Visual Studio Code Explorer sidebar.
- Name the file index.html.
- Build the page structure by pasting this code into index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Habit Tracker</title>
<style>
</style>
</head>
<body>
<main>
<h1>Habit Tracker</h1>
<p>Add one small habit you want to repeat.</p>
<form id="habit-form">
<input id="habit-name" required placeholder="Example: Drink water">
<button type="submit">Add habit</button>
</form>
<p id="status" role="status"></p>
<ul id="habit-list"></ul>
</main>
<script>
</script>
</body>
</html>
What does this code do?
- The main element groups the tracker into one browser interface.
- The #habit-form form collects a habit name from #habit-name.
- The #status area has a status role so future validation messages have a clear destination.
- The #habit-list element gives each saved habit a place on the page.
- Save index.html.
- Right-click index.html in the Visual Studio Code Explorer sidebar.
- Select Reveal in File Explorer.
- Drag index.html from File Explorer into Microsoft Edge.
That first screen is in place. You will see the Habit Tracker heading with its input and button.
Page missing its form?
- Confirm that the file is named index.html.
- Check that the form sits between the opening and closing main tags.
Still missing the form? Help me check the structure of my Habit Tracker HTML.
The structure is functional. CSS now turns it into a focused tracker instead of a collection of unstyled controls.
- Return to Visual Studio Code.
- Place your cursor between the style tags in index.html.
- Style the page container by pasting this code:
body {
max-width: 680px;
margin: 48px auto;
padding: 0 20px;
font-family: Arial, sans-serif;
color: #172033;
background: #f4f7fb;
}
main {
padding: 28px;
border-radius: 16px;
background: white;
box-shadow: 0 10px 30px rgba(23, 32, 51, 0.12);
}
What do these styles change?
- The body rule limits the page width and centers the tracker.
- The main rule creates the white card around the interface.
- The background and shadow separate the card from the rest of the page.
- Save index.html.
- Return to Microsoft Edge.
- Refresh the local file.
You will see the Habit Tracker inside a centered white card on a pale background.
Still seeing an unstyled page?
- Confirm that the new rules sit between the opening and closing style tags.
- Check that index.html is saved before refreshing Microsoft Edge.
Need another pair of eyes? Help me find why the body and main styles are not showing.
- Place your cursor below the main rule.
- Style the form controls and message area by pasting this code:
form {
display: flex;
gap: 10px;
}
input,
button {
padding: 10px 12px;
font: inherit;
}
input {
flex: 1;
}
button {
border: 0;
border-radius: 8px;
color: white;
background: #315efb;
cursor: pointer;
}
li {
margin: 10px 0;
}
#status {
min-height: 24px;
color: #a12a2a;
}
How do these controls fit together?
- The form uses a flexible row so the input and button sit beside each other.
- The input expands to use the available width.
- The button styling makes the form action easy to identify.
- The status area keeps space available for future error messages.
- Save index.html.
- Return to Microsoft Edge.
- Refresh the local file.
You will see a wide input beside a blue Add habit button.
Form controls still stacked?
- Check that the selector begins with form.
- Confirm that the form rule includes display: flex.
Still stuck? Help me debug why my Habit Tracker form is not using its CSS layout.
The habit list begins with no data. A small JavaScript function makes that empty state visible to the learner.
- Place your cursor between the script tags near the bottom of index.html.
- Render the empty habit list by pasting this code:
const list = document.querySelector('#habit-list');
function renderHabits(habits) {
list.textContent = '';
if (habits.length === 0) {
const emptyItem = document.createElement('li');
emptyItem.textContent = 'No habits yet.';
list.appendChild(emptyItem);
return;
}
for (const habit of habits) {
const item = document.createElement('li');
item.textContent = habit.name;
list.appendChild(item);
}
}
renderHabits([]);
What does the empty-state script do?
- The list constant stores the page element with the #habit-list ID.
- The renderHabits() function clears old list content before drawing the current state.
- An empty array creates a list item containing No habits yet..
- The loop provides the rendering path that later API responses will use.
- Save index.html.
- Return to Microsoft Edge.
- Refresh the local file.
You will see No habits yet. below the form.
Empty message missing?
- Confirm that the script appears after the #habit-list element.
- Check that the final line calls renderHabits([]).
Need help tracing the script? Help me find why No habits yet. is missing from my Habit Tracker.
Build the local Node.js server
Opening index.html directly proves the page works. An HTTP server gives the browser a local address that can support API routes next.
The server uses an ES module so it can import Node.js built-ins from node:http and node:fs/promises.
- Select the new-file icon beside habit-tracker in the Visual Studio Code Explorer sidebar.
- Name the file index.mjs.
- Add the server imports and configuration by pasting this code:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const HOST = '127.0.0.1';
const PORT = 3000;
const PAGE_FILE = new URL('./index.html', import.meta.url);
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
What does this server setup define?
- The imports provide the server constructor and asynchronous file reading.
- The HOST value limits the server to your computer.
- The PORT value gives the browser a predictable local destination.
- The sendJson() helper sends structured JSON responses with an HTTP status code.
- Save index.mjs.
- Confirm that the Visual Studio Code Explorer sidebar lists index.html and index.mjs.
You will see both project files inside the habit-tracker folder.
Server file missing?
- Confirm that index.mjs appears at the same folder level as index.html.
- Check that the filename ends with .mjs.
Need help checking the files? Help me confirm that index.html and index.mjs are in the same habit-tracker folder.
The request handler decides which response every browser request receives. The home route gets the HTML page while every unknown route gets a JSON error.
- Place your cursor below the closing brace of sendJson().
- Add the request handler and startup logic by pasting this code:
const server = createServer(async (request, response) => {
const url = new URL(request.url, `http://${HOST}:${PORT}`);
if (request.method === 'GET' && url.pathname === '/') {
const page = await readFile(PAGE_FILE, 'utf8');
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end(page);
return;
}
sendJson(response, 404, { error: 'Route not found' });
});
server.listen(PORT, HOST, () => {
console.log(`Habit Tracker running at http://${HOST}:${PORT}`);
});
How does the server route requests?
- The createServer() callback receives each request and its response.
- A GET request for / reads index.html as text.
- The successful route sends the page with the text/html content type.
- Every unmatched route receives a 404 status with a JSON error.
- The listen() call starts the server on the configured host and port.
- Save index.mjs.
Run and verify the starter tracker
The server process stays active so it can answer browser requests. Its first terminal message confirms that the local address is ready.
Before you start the server, what address do you expect its confirmation message to contain?
- Start the Habit Tracker server from the integrated terminal by running this command:
node index.mjs
What should the terminal show?
You will see Habit Tracker running at http://127.0.0.1:3000 in the terminal. The active process keeps that local address available to Microsoft Edge.
Before you load the address, which page elements do you expect Node.js to send to Microsoft Edge?
- Return to Microsoft Edge.
- Enter http://127.0.0.1:3000 in the address bar.
- Press Enter.
- Position Microsoft Edge beside Visual Studio Code.
You will see the styled Habit Tracker heading and form. You will also see No habits yet. below the form.
The connection works. Microsoft Edge is now displaying a page delivered by your running Node.js process.
Local page not loading?
- Confirm that the integrated terminal still shows the running server message.
- Check that the address uses http://127.0.0.1:3000.
- Confirm that index.html and index.mjs are saved in the same folder.
Still unable to load the page? Help me debug why my local Habit Tracker server is not loading in Microsoft Edge.
✔️ Awesome, I've got everything!
Your files are saved. Keep the server running while you compare the final page with the reference.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Habit Tracker</title>
<style>
body {
max-width: 680px;
margin: 48px auto;
padding: 0 20px;
font-family: Arial, sans-serif;
color: #172033;
background: #f4f7fb;
}
main {
padding: 28px;
border-radius: 16px;
background: white;
box-shadow: 0 10px 30px rgba(23, 32, 51, 0.12);
}
form {
display: flex;
gap: 10px;
}
input,
button {
padding: 10px 12px;
font: inherit;
}
input {
flex: 1;
}
button {
border: 0;
border-radius: 8px;
color: white;
background: #315efb;
cursor: pointer;
}
li {
margin: 10px 0;
}
#status {
min-height: 24px;
color: #a12a2a;
}
</style>
</head>
<body>
<main>
<h1>Habit Tracker</h1>
<p>Add one small habit you want to repeat.</p>
<form id="habit-form">
<input id="habit-name" required placeholder="Example: Drink water">
<button type="submit">Add habit</button>
</form>
<p id="status" role="status"></p>
<ul id="habit-list"></ul>
</main>
<script>
const list = document.querySelector('#habit-list');
function renderHabits(habits) {
list.textContent = '';
if (habits.length === 0) {
const emptyItem = document.createElement('li');
emptyItem.textContent = 'No habits yet.';
list.appendChild(emptyItem);
return;
}
for (const habit of habits) {
const item = document.createElement('li');
item.textContent = habit.name;
list.appendChild(item);
}
}
renderHabits([]);
</script>
</body>
</html>
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const HOST = '127.0.0.1';
const PORT = 3000;
const PAGE_FILE = new URL('./index.html', import.meta.url);
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
const server = createServer(async (request, response) => {
const url = new URL(request.url, `http://${HOST}:${PORT}`);
if (request.method === 'GET' && url.pathname === '/') {
const page = await readFile(PAGE_FILE, 'utf8');
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end(page);
return;
}
sendJson(response, 404, { error: 'Route not found' });
});
server.listen(PORT, HOST, () => {
console.log(`Habit Tracker running at http://${HOST}:${PORT}`);
});
Your browser and server are connected. Next, you will turn the static tracker into an app that creates habits through a JSON API.
Build the In-Memory Habit API
Your Node.js server already sends the Habit Tracker page to Microsoft Edge. The form currently has nowhere to send a habit.
An in-memory HTTP API gives the browser a path for creating habits and requesting the current list. You will restart the process after it works to test whether the array protects your data.
In this step, get ready to:
- Define the rules for creating a habit.
- Expose habit creation through two API routes.
- Connect the browser form to the API.
Create the habit rule
Habit creation belongs in its own function so the server has one place for name cleanup, validation, and ID assignment. The function receives the current array because that is where the next available ID comes from.
- Return to the integrated terminal in Visual Studio Code.
- Stop the running server by pressing Ctrl+C.
- Use the file sidebar's new-file control to create habits.mjs inside the habit-tracker folder.
- Build the habit creation rule by pasting this code into habits.mjs:
export function createHabit(habits, name) {
const cleanName = typeof name === 'string' ? name.trim() : '';
if (!cleanName) {
throw new TypeError('Habit name is required');
}
const nextId = habits.reduce(
(highestId, habit) => Math.max(highestId, habit.id),
0,
) + 1;
return {
id: nextId,
name: cleanName,
completed: false,
};
}
What does this code do?
- The cleanName value removes spaces from both ends of a string.
- The TypeError prevents empty names from becoming habits.
- The reduce() call finds the highest existing numeric ID.
- The returned object starts every new habit with completed: false.
- Save habits.mjs.
- Confirm that habits.mjs remains listed beside index.mjs in the file sidebar.
Cannot find the new module?
Check that habits.mjs sits directly inside habit-tracker. A file created inside another folder cannot be reached by the import used next.
If the file still looks misplaced, help me check where habits.mjs should be saved in my Habit Tracker project.
Add the API routes
The server needs temporary state plus a way to decode each JSON request body. It also needs one route for reading the array and another route for adding a habit.
- Select index.mjs in the Visual Studio Code file sidebar.
- Replace the imports and constants at the top of index.mjs through the PAGE_FILE declaration with this code:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { createHabit } from './habits.mjs';
const HOST = '127.0.0.1';
const PORT = 3000;
const PAGE_FILE = new URL('./index.html', import.meta.url);
let habits = [];
What does this server setup do?
- The relative import gives index.mjs access to createHabit().
- The habits array stores every created habit while this server process is running.
- The existing host, port, and page file values keep the browser address unchanged.
- Save index.mjs.
- Start the updated server by running:
node index.mjs
What should I see?
You should see the Habit Tracker local address in the terminal. The process stays active because the server is waiting for browser requests.
- Switch back to Microsoft Edge.
- Refresh the local Habit Tracker page.
You should still see the Habit Tracker heading and the empty-state message. This confirms that the new module import loads without breaking the server.
- Return to the integrated terminal.
- Stop the server by pressing Ctrl+C.
Server does not start?
Check that the import path ends with ./habits.mjs. Confirm that both files are inside the same habit-tracker folder.
If the import still fails, help me troubleshoot why index.mjs cannot import createHabit from habits.mjs.
An incoming request body is a readable stream. The server collects its text chunks before parsing the complete JSON value.
- Find the closing brace of sendJson() in index.mjs.
- Add the request-body reader immediately below sendJson() by pasting this code:
async function readJsonBody(request) {
request.setEncoding('utf8');
let body = '';
for await (const chunk of request) {
body += chunk;
}
return JSON.parse(body);
}
How does body reading work?
- The utf8 encoding makes each incoming chunk a string.
- The asynchronous loop consumes the request stream one chunk at a time.
- The final JSON.parse() call turns the collected text into a JavaScript value.
- Save index.mjs.
- Check the body reader for syntax errors by running:
node index.mjs
What does this check prove?
You should see the local Habit Tracker address again. That startup message proves Node.js can parse the new asynchronous function.
- Switch back to Microsoft Edge.
- Refresh the Habit Tracker page.
You should see the same heading and empty-state message. The server still handles the page route after adding the request-body reader.
- Return to the integrated terminal.
- Stop the server by pressing Ctrl+C.
Seeing a syntax error?
Check that readJsonBody() sits outside sendJson(). Confirm that the asynchronous loop has both closing braces.
If the terminal points to the new function, help me find the syntax error in my readJsonBody function.
The API now has everything it needs to read and update its temporary array. Each matching route ends its response early so the later 404 response only handles unknown paths.
- Find the existing GET / route inside the server handler.
- Add the two API routes immediately below that route by pasting this code:
if (request.method === 'GET' && url.pathname === '/api/habits') {
sendJson(response, 200, habits);
return;
}
if (request.method === 'POST' && url.pathname === '/api/habits') {
const input = await readJsonBody(request);
const habit = createHabit(habits, input.name);
habits = [...habits, habit];
sendJson(response, 200, habit);
return;
}
What do these routes do?
- The GET /api/habits route returns the current in-memory array.
- The POST /api/habits route parses the submitted name.
- The creation helper validates the name before the new object enters the array.
- The successful response returns the habit that was just created.
- Find the existing catch block at the bottom of the server handler.
- Replace that block with this error handling code:
} catch (error) {
if (error instanceof SyntaxError || error instanceof TypeError) {
sendJson(response, 400, { error: error.message });
return;
}
console.error(error);
sendJson(response, 500, { error: 'Internal server error' });
}
});
How are errors returned?
- A malformed JSON body produces a client-facing response with status code 400.
- A blank habit name produces the same status code with the helper's validation message.
- An unexpected server failure is logged in the terminal before a generic response is sent.
- Save index.mjs.
- Start the server with its new API routes by running:
node index.mjs
What should I see?
You should see the local Habit Tracker address in the terminal. Keep this process running while you check the new route.
Before you check the API, what value do you expect from an array with no habits?
- Switch back to Microsoft Edge.
- Enter http://127.0.0.1:3000/api/habits in the address bar.
You should see [] in Microsoft Edge. This proves the browser can request the server's in-memory habit list.
API route returns an error?
Confirm that the API route appears before the JSON 404 response in index.mjs. Check the terminal for a missing brace near the route you added.
If the route still falls through to the 404 response, help me troubleshoot why GET /api/habits is not returning my habits array.
Connect the browser to the API
The browser can reach the API directly. The final connection uses fetch() to load the list and submit the form without leaving the page.
- Enter http://127.0.0.1:3000 in the Microsoft Edge address bar.
- Return to index.html in Visual Studio Code.
- Replace the existing renderHabits() function with this expanded version:
function renderHabits(habits) {
list.textContent = '';
if (habits.length === 0) {
const emptyItem = document.createElement('li');
emptyItem.textContent = 'No habits yet.';
list.appendChild(emptyItem);
return;
}
for (const habit of habits) {
const item = document.createElement('li');
item.textContent = habit.name;
list.appendChild(item);
}
}
How does the list render?
- The function clears the old list before drawing the latest server response.
- An empty array still produces the No habits yet. message.
- A non-empty array creates one list item for each habit name.
- Save index.html.
- Refresh Microsoft Edge.
You should still see No habits yet. below the form. This confirms that the expanded function preserves the empty state.
Empty message disappeared?
Check that the empty-state condition uses habits.length === 0. Confirm that emptyItem is appended to list.
If the list stays blank, help me troubleshoot why renderHabits does not show the empty-state item.
- Find the existing renderHabits([]) call near the bottom of the script.
- Replace that call with the API loader and form handler below:
async function loadHabits() {
const response = await fetch('/api/habits');
const habits = await response.json();
renderHabits(habits);
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
status.textContent = '';
const response = await fetch('/api/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: input.value }),
});
const result = await response.json();
if (!response.ok) {
status.textContent = result.error;
return;
}
input.value = '';
await loadHabits();
});
loadHabits();
How does the browser use the API?
- The loadHabits() function requests the current array before rendering it.
- The submit listener prevents the form from reloading the whole page.
- The POST request sends the input value as JSON.
- A failed response places the API error in the status area.
- A successful response clears the input before loading the updated array.
- Save index.html.
- Refresh Microsoft Edge.
You should see the Habit Tracker form with No habits yet. beneath it. The message now comes from data loaded through the API.
Browser list does not load?
Keep the node index.mjs process running in the integrated terminal. Check that both browser requests use the exact /api/habits path.
If the page still cannot load or submit habits, help me troubleshoot the fetch requests between index.html and my Node.js API.
Use this comparison if you want to check the complete in-memory version before testing it.
✔️ Awesome, I've got everything!
Great. Confirm that all three files are saved before you test the complete browser flow.
ⓧ I'd like to double check the full code
export function createHabit(habits, name) {
const cleanName = typeof name === 'string' ? name.trim() : '';
if (!cleanName) {
throw new TypeError('Habit name is required');
}
const nextId = habits.reduce(
(highestId, habit) => Math.max(highestId, habit.id),
0,
) + 1;
return {
id: nextId,
name: cleanName,
completed: false,
};
}
What should match in habits.mjs?
This file contains the complete habit creation rule. Compare the validation, ID calculation, and returned object with your version.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { createHabit } from './habits.mjs';
const HOST = '127.0.0.1';
const PORT = 3000;
const PAGE_FILE = new URL('./index.html', import.meta.url);
let habits = [];
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
async function readJsonBody(request) {
request.setEncoding('utf8');
let body = '';
for await (const chunk of request) {
body += chunk;
}
return JSON.parse(body);
}
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url, `http://${HOST}:${PORT}`);
if (request.method === 'GET' && url.pathname === '/') {
const page = await readFile(PAGE_FILE, 'utf8');
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end(page);
return;
}
if (request.method === 'GET' && url.pathname === '/api/habits') {
sendJson(response, 200, habits);
return;
}
if (request.method === 'POST' && url.pathname === '/api/habits') {
const input = await readJsonBody(request);
const habit = createHabit(habits, input.name);
habits = [...habits, habit];
sendJson(response, 200, habit);
return;
}
sendJson(response, 404, { error: 'Route not found' });
} catch (error) {
if (error instanceof SyntaxError || error instanceof TypeError) {
sendJson(response, 400, { error: error.message });
return;
}
console.error(error);
sendJson(response, 500, { error: 'Internal server error' });
}
});
server.listen(PORT, HOST, () => {
console.log(`Habit Tracker running at http://${HOST}:${PORT}`);
});
What should match in index.mjs?
This file contains the complete in-memory server. Compare the import, temporary array, body reader, API routes, and error handling with your version.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Habit Tracker</title>
<style>
body {
max-width: 680px;
margin: 48px auto;
padding: 0 20px;
font-family: Arial, sans-serif;
color: #172033;
background: #f4f7fb;
}
main {
padding: 28px;
border-radius: 16px;
background: white;
box-shadow: 0 10px 30px rgba(23, 32, 51, 0.12);
}
form {
display: flex;
gap: 10px;
}
input,
button {
padding: 10px 12px;
font: inherit;
}
input {
flex: 1;
}
button {
border: 0;
border-radius: 8px;
color: white;
background: #315efb;
cursor: pointer;
}
li {
margin: 10px 0;
}
#status {
min-height: 24px;
color: #a12a2a;
}
</style>
</head>
<body>
<main>
<h1>Habit Tracker</h1>
<p>Add one small habit you want to repeat.</p>
<form id="habit-form">
<input id="habit-name" required placeholder="Example: Drink water">
<button type="submit">Add habit</button>
</form>
<p id="status" role="status"></p>
<ul id="habit-list"></ul>
</main>
<script>
const form = document.querySelector('#habit-form');
const input = document.querySelector('#habit-name');
const status = document.querySelector('#status');
const list = document.querySelector('#habit-list');
function renderHabits(habits) {
list.textContent = '';
if (habits.length === 0) {
const emptyItem = document.createElement('li');
emptyItem.textContent = 'No habits yet.';
list.appendChild(emptyItem);
return;
}
for (const habit of habits) {
const item = document.createElement('li');
item.textContent = habit.name;
list.appendChild(item);
}
}
async function loadHabits() {
const response = await fetch('/api/habits');
const habits = await response.json();
renderHabits(habits);
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
status.textContent = '';
const response = await fetch('/api/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: input.value }),
});
const result = await response.json();
if (!response.ok) {
status.textContent = result.error;
return;
}
input.value = '';
await loadHabits();
});
loadHabits();
</script>
</body>
</html>
What should match in index.html?
This file contains the complete browser interface for this step. Compare the rendering function, loader, submit handler, and final initial load with your version.
The full browser flow is ready. Start by checking that invalid input reaches the server's validation rule.
- Click inside the habit input in Microsoft Edge.
- Type three spaces into the input.
- Click Add habit.
You should see Habit name is required in the status area. This proves the browser displays validation errors returned by the API.
- Click inside the habit input.
- Press Ctrl+A.
- Type Drink water.
- Click Add habit.
You should see Drink water beneath the form. That is your first complete browser-to-server habit flow working.
Before you restart the server, do you think Drink water will still appear when the process starts again?
- Return to the integrated terminal.
- Stop the server by pressing Ctrl+C.
- Restart the server by running:
node index.mjs
What does the restart prove?
The terminal should show the local Habit Tracker address again. This is a new server process with a newly created in-memory array.
- Switch back to Microsoft Edge.
- Refresh the Habit Tracker page.
You should see No habits yet. again. The restart erased the array, so the habit disappeared exactly as this in-memory design allows.
Your browser and API now move habits from the form into server memory. Next, you will replace that temporary array with file storage so a restart cannot erase your list.
Persist Habits to a JSON File
Your browser can already send habits to the server through a working API. Restarting the Node.js process exposed the gap.
The in-memory array disappears when the process stops. Persistence gives those habits a home on disk through a JSON file.
In this step, get ready to:
- Create habits.json as the app's data store.
- Add asynchronous helpers that load or save the habit array.
- Move the API storage from memory to habits.json.
Why Use a JSON File?
A JSON file keeps every stored habit visible while you learn how persistence works. You can inspect the exact data that the server reads.
A database would add extra setup that this local tracker does not need. The file keeps your attention on the storage flow.
Create the JSON data file
The server needs an initial array that it can read before any habits exist. The new file starts with the same empty shape that your in-memory array used.
- In the Visual Studio Code Explorer sidebar, select the file creation icon.
- Enter habits.json as the file name.
- Press Enter.
- Initialize the habit array by copying this content into habits.json:
[]
Why Start With an Empty Array?
The [] value is a valid JSON array with no entries. Your storage helper can parse it immediately.
- Save habits.json.
- Confirm that habits.json appears beside your existing project files in the Explorer sidebar.
You'll see [] in the editor. The tracker now has a valid storage file.
Can't See the New File?
Check that you created habits.json inside the habit-tracker folder. Confirm that its name does not have an extra file extension.
Still stuck? Help me check why habits.json is missing from my habit-tracker folder.
✔️ Awesome, I've got everything!
Your empty data file is saved inside habit-tracker.
ⓧ I'd like to double check the full code
[]
What Should Match?
Your entire habits.json file should contain one empty JSON array.
Add asynchronous storage helpers
Node.js provides promise-based file helpers for asynchronous reads and writes. A module-relative URL keeps habits.json connected to habits.mjs even when the terminal starts elsewhere.
- Select habits.mjs in the Explorer sidebar.
- Place your cursor before export function createHabit(habits, name) {.
- Add the file-system import and data-file location by copying this code:
import { readFile, writeFile } from 'node:fs/promises';
const DATA_FILE = new URL('./habits.json', import.meta.url);
What Does This Code Do?
- The node:fs/promises import provides asynchronous file operations that work with await.
- DATA_FILE stores a URL pointing to habits.json beside the current module.
- Move your cursor below the closing brace of createHabit().
- Add the read and write helpers by copying this code:
export async function readHabits() {
try { const contents = await readFile(DATA_FILE, 'utf8'); return JSON.parse(contents); }
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
}
export async function writeHabits(habits) { await writeFile(DATA_FILE, JSON.stringify(habits, null, 2)); }
How Do the Helpers Work?
- readHabits() loads the complete file as UTF-8 text. It converts that text into a JavaScript array.
- ENOENT identifies a missing file. The helper returns an empty array in that case.
- writeHabits() converts the array into indented JSON. It writes the result to the data file.
- Save habits.mjs.
- Switch back to the Visual Studio Code terminal from earlier.
- Stop the running server by pressing Ctrl+C.
- Check that the updated module still loads by restarting the server with this command:
node index.mjs
What Does This Restart Check?
Restarting imports the updated habits.mjs file into a fresh process. A running server confirms that the new module code can load.
- Refresh Microsoft Edge.
You'll see Habit Tracker running at http://127.0.0.1:3000 in the terminal. The browser shows No habits yet..
Good progress. Your storage helpers now load without breaking the running tracker.
Server Won't Restart?
Check that the import sits at the top of habits.mjs. Confirm that DATA_FILE appears before the exported functions.
Need another pair of eyes? Help me debug the storage helpers in habits.mjs.
✔️ Awesome, I've got everything!
Your storage helpers are saved. The server can import them successfully.
ⓧ I'd like to double check the full code
import { readFile, writeFile } from 'node:fs/promises';
const DATA_FILE = new URL('./habits.json', import.meta.url);
export function createHabit(habits, name) {
const cleanName = typeof name === 'string' ? name.trim() : '';
if (!cleanName) throw new TypeError('Habit name is required');
const nextId = habits.reduce((highestId, habit) => Math.max(highestId, habit.id), 0) + 1;
return { id: nextId, name: cleanName, completed: false };
}
export async function readHabits() {
try { const contents = await readFile(DATA_FILE, 'utf8'); return JSON.parse(contents); }
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
}
export async function writeHabits(habits) { await writeFile(DATA_FILE, JSON.stringify(habits, null, 2)); }
What Should Match?
Your file should keep createHabit() between the data-file configuration and the two storage helpers.
Connect the API to the file
The storage helpers exist, but the API routes still use the temporary array. Each request now needs to load the latest file contents.
- Select index.mjs in the Explorer sidebar.
- Find the import that currently reads import { createHabit } from './habits.mjs';.
- Replace that import by copying this code:
import { createHabit, readHabits, writeHabits } from './habits.mjs';
Why Expand the Import?
The server keeps using createHabit() for its validation rules. It can now call the new storage helpers from each API route.
- Save index.mjs.
- Stop the server by pressing Ctrl+C.
- Verify the expanded import by restarting the server with this command:
node index.mjs
What Does This Check Prove?
A successful restart proves that all three imported functions are available. The API still uses memory until you update its routes.
You'll see the local Habit Tracker address in the terminal. The process remains ready for requests.
- In index.mjs, find the condition for GET /api/habits.
- Replace the complete condition by copying this code:
if (request.method === 'GET' && url.pathname === '/api/habits') { const habits = await readHabits(); sendJson(response, 200, habits); return; }
How Does the GET Route Change?
Every GET request now waits for readHabits(). The response contains the array parsed from habits.json.
- Save index.mjs.
- Stop the server by pressing Ctrl+C.
Before you restart, what do you expect the browser to show when the GET route reads the empty array from habits.json?
- Test the file-backed GET route by running this command:
node index.mjs
What Does This Run Test?
The restarted server executes the updated GET route when the browser requests its habits. That route now reads the file instead of the old array.
- Refresh Microsoft Edge.
You'll see No habits yet. because habits.json currently contains an empty array.
- Return to index.mjs.
- Find the condition for POST /api/habits.
- Replace the complete condition by copying this code:
if (request.method === 'POST' && url.pathname === '/api/habits') { const input = await readJsonBody(request); const habits = await readHabits(); const habit = createHabit(habits, input.name); await writeHabits([...habits, habit]); sendJson(response, 200, habit); return; }
How Does the POST Route Persist Data?
The route loads the current array before calculating the next ID. It writes a new array containing the created habit back to disk.
The server sends the new habit only after the write finishes. A successful response means the file update completed.
- Find the line let habits = [];.
- Delete the in-memory array declaration.
- Save index.mjs.
- Stop the server by pressing Ctrl+C.
- Start the file-backed server by running this command:
node index.mjs
What Changed in This Process?
This process has no in-memory habit array. Both API routes now depend on habits.json.
- Refresh Microsoft Edge.
- Enter Read for 10 minutes in the habit field.
- Click Add habit.
- Switch back to Visual Studio Code.
- Select habits.json in the Explorer sidebar.
You'll see a JSON object for Read for 10 minutes inside the array. Its completed value is false.
- Stop the server by pressing Ctrl+C.
Before you restart, do you think Read for 10 minutes will return in the browser?
- Run the persistence check by starting a fresh server process:
node index.mjs
Why Is This Restart Definitive?
A fresh process cannot access the previous process's memory. Any habit that returns must have come from habits.json.
- Refresh Microsoft Edge.
You'll see Read for 10 minutes return to the list. That's the data-loss problem solved: your habit now survives a complete server restart.
Habit Missing After the Restart?
Check that the POST route awaits writeHabits([...habits, habit]). Confirm that the GET route awaits readHabits().
Still losing the habit? Help me trace why my habit is not persisting after a Node.js restart.
✔️ Awesome, I've got everything!
Your API now reads habits from disk. New habits are written back to the same file.
ⓧ I'd like to double check the full code
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { createHabit, readHabits, writeHabits } from './habits.mjs';
const HOST = '127.0.0.1';
const PORT = 3000;
const PAGE_FILE = new URL('./index.html', import.meta.url);
function sendJson(response, statusCode, payload) { response.writeHead(statusCode, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(payload)); }
async function readJsonBody(request) { request.setEncoding('utf8'); let body = ''; for await (const chunk of request) { body += chunk; } return JSON.parse(body); }
const server = createServer(async (request, response) => { try { const url = new URL(request.url, `http://${HOST}:${PORT}`); if (request.method === 'GET' && url.pathname === '/') { const page = await readFile(PAGE_FILE, 'utf8'); response.writeHead(200, { 'Content-Type': 'text/html' }); response.end(page); return; } if (request.method === 'GET' && url.pathname === '/api/habits') { const habits = await readHabits(); sendJson(response, 200, habits); return; } if (request.method === 'POST' && url.pathname === '/api/habits') { const input = await readJsonBody(request); const habits = await readHabits(); const habit = createHabit(habits, input.name); await writeHabits([...habits, habit]); sendJson(response, 200, habit); return; } sendJson(response, 404, { error: 'Route not found' }); } catch (error) { if (error instanceof SyntaxError || error instanceof TypeError) { sendJson(response, 400, { error: error.message }); return; } console.error(error); sendJson(response, 500, { error: 'Internal server error' }); } });
server.listen(PORT, HOST, () => { console.log(`Habit Tracker running at http://${HOST}:${PORT}`); });
What Should Match?
Your completed server file should import both storage helpers. Its GET and POST routes should use those helpers without declaring an in-memory habit array.
Your tracker now survives a restart because the server reloads every habit from disk. Next, you'll protect its creation rules with automated tests.
Test the Habit Creation Rules
Your persistent Habit Tracker now keeps its entries after every server restart. You have a working browser flow backed by local storage.
Future edits could still break name cleanup, ID assignment, or blank-name validation. Automated tests turn those rules into repeatable checks that catch a regression before it reaches the browser.
In this step, get ready to:
- Create a test file for the habit creation rules.
- Verify cleaned names, incremented IDs, and blank-name validation.
- Run the complete test suite and confirm the browser app still loads.
Test cleaned names and incremented IDs
Node.js includes a test runner and strict assertion tools. Your first test gives createHabit() an existing habit, then checks the exact object returned for a new name.
- In Visual Studio Code, select the new-file control beside the habit-tracker folder.
- Enter habits.test.mjs as the file name.
You should see an empty habits.test.mjs editor tab. The file sits beside habits.mjs, so its relative import can reach the helper directly.
- Add the imports and first habit rule test by copying this code into habits.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHabit } from './habits.mjs';
test('createHabit trims the name and assigns the next ID', () => {
const existingHabits = [{ id: 2, name: 'Stretch', completed: false }];
const habit = createHabit(existingHabits, ' Drink water ');
assert.deepStrictEqual(habit, { id: 3, name: 'Drink water', completed: false });
});
What Does This Test Prove?
- The test imports createHabit from the same module used by the server.
- The spaced name checks that the helper removes surrounding whitespace.
- The existing ID of 2 makes the expected next ID 3.
- The strict deep comparison checks every property in the returned habit object.
- Save habits.test.mjs.
- Create a second integrated terminal from the terminal panel in Visual Studio Code.
- Run the first test by entering this command:
node --test
What Should You See?
The test runner should report one passing test with no failures. This proves that createHabit() cleans the name and advances the ID without involving the server or browser.
First Test Not Passing?
- Confirm habits.test.mjs is saved inside the habit-tracker folder.
- Check that the import points to ./habits.mjs with the file extension included.
- Compare the expected habit object with the code above for a missing property or mistyped value.
Still stuck? Help me diagnose why my first habit creation test is failing.
Test blank-name validation
A blank habit would leave the tracker with a meaningless entry. The second test confirms that createHabit() throws the expected TypeError when trimming leaves no name.
- Append the blank-name test by copying this code to the end of habits.test.mjs:
test('createHabit rejects a blank name', () => {
assert.throws(() => createHabit([], ' '), { name: 'TypeError', message: 'Habit name is required' });
});
What Does This Test Prove?
- The spaces create an input that becomes empty after trimming.
- The exception assertion confirms that the helper rejects the invalid value.
- The validation object checks the error type and its exact message.
- Save habits.test.mjs.
✔️ Awesome, I've got everything!
Your saved test file now covers successful habit creation and blank-name rejection.
ⓧ I'd like to double check the full code
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHabit } from './habits.mjs';
test('createHabit trims the name and assigns the next ID', () => {
const existingHabits = [{ id: 2, name: 'Stretch', completed: false }];
const habit = createHabit(existingHabits, ' Drink water ');
assert.deepStrictEqual(habit, { id: 3, name: 'Drink water', completed: false });
});
test('createHabit rejects a blank name', () => {
assert.throws(() => createHabit([], ' '), { name: 'TypeError', message: 'Habit name is required' });
});
How Is This File Organized?
The imports connect the test runner, strict assertions, and the production helper. Each test then protects one distinct path through createHabit().
Verify the tests and browser flow
The complete suite now checks both the valid and invalid paths through habit creation. This final run reveals whether both rules work together.
Before you run the suite, do you expect both tests to pass after adding the blank-name case?
- Return to the second integrated terminal.
- Run the complete test suite by entering this command:
node --test
Read the Test Report
You should see two passing tests and zero failures. One result protects cleaned names and incremented IDs. The other protects blank-name validation.
That is your safety net in place. Future changes now have to preserve both creation rules before the suite can pass.
Test Suite Showing a Failure?
- Check that both test() calls have matching braces and parentheses.
- Confirm the blank-name test passes three spaces to createHabit().
- Confirm the expected error message matches Habit name is required exactly.
Need another pair of eyes? Help me troubleshoot my two failing or incomplete Node.js habit tests.
The tests exercise the helper in isolation. A final browser check confirms the tested helper still belongs to a working local app.
- Return to the server terminal from earlier.
- Stop the running server process.
- Restart the Habit Tracker by running this command:
node index.mjs
What Does This Command Confirm?
The command starts the persistent server with the same createHabit() helper covered by your tests. The terminal should print the local Habit Tracker address.
- Return to Microsoft Edge from earlier.
- Refresh http://127.0.0.1:3000.
You should see the Habit Tracker heading and No habits yet. because habits.json still contains an empty array.
Strong finish. Your persistent Habit Tracker still runs in the browser, and two automated checks now guard its creation rules.
Secret mission
Mark Habits Complete
Your tracker remembers habits, but every habit stays unfinished forever. You will add a completion rule to the habit module. An HTTP route will persist each change. A browser button will reveal the result.
Clean Up Your Resources
Clean Up Your Resources
Your Habit Tracker runs entirely on your computer, so the project creates no ongoing costs. Choose whether to leave it running, stop the server for later, or remove the local project.
Resources you used:
- A local Node.js server process running index.mjs.
- The local habit-tracker folder.
- The browser interface in index.html.
- The HTTP server in index.mjs.
- The habit logic in habits.mjs.
- The automated tests in habits.test.mjs.
- The saved habit data in habits.json.
Keep everything running
No action is needed. Choose this if you want to continue adding habits or marking them complete.
- Leave the local server process running while you continue using the tracker.
- Keep all five project files inside habit-tracker.
- Return to Microsoft Edge at http://127.0.0.1:3000 whenever you want to use the app.
Pause - I'll come back to this later
Stop the server process to free the terminal. Your project files remain intact in habit-tracker.
- Switch back to the integrated terminal in Visual Studio Code from earlier.
- Press Ctrl+C to stop the local server.
- Confirm the terminal returns to its command prompt.
- Keep the habit-tracker folder so your code remains available.
- Keep habits.json so your saved habits remain available when you return.
Delete - I don't want to use this again
Remove the local server process and the complete project folder. This clears the app code, tests, and saved habits from their original location.
Save anything you need
Deleting the habit-tracker folder removes every project file from its original location.
- Copy any code or saved habits you want to keep before you continue.
- Switch back to the integrated terminal from earlier.
- Press Ctrl+C to stop the local server.
- Confirm the terminal returns to its command prompt.
- Press the Windows key to open Windows search.
- Type File Explorer into the search field.
- Press Enter to open Windows File Explorer.
- Locate the habit-tracker folder in the location where you created it.
- Select the habit-tracker folder.
- Press the Delete key to remove the folder.
- Confirm the habit-tracker folder no longer appears in its original location.
That completes the local cleanup. The server is stopped and the full Habit Tracker project has been removed from its original location.
Nice Work!
Nice Work!
You did it! Your Habit Tracker now carries habits from Microsoft Edge through a Node.js API into durable local storage. Automated checks protect its habit creation rules.
You've learned how to:
- Built a browser flow that displays each submitted habit immediately.
- Created a framework-free HTTP API. Its GET /api/habits route loads entries. Its POST /api/habits route validates new habits before creation.
- Exposed in-memory data loss by restarting the server. Replaced temporary state with JSON-file persistence. Protected createHabit() with automated tests.
- Secret Mission: Added a persistent completion toggle with toggleHabit(). Connected the browser button to PATCH /api/habits/:id/toggle. Kept each habit's Done state intact across server restarts.
Ready to quiz yourself?