Original notes for JavaScript360.org — project concept inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke, built here with an original design and code. See the full attribution note.
Chapter 21 · Project: A Skill-Sharing Platform
Estimated time: ~35 minutes · Chapter type: Guided project (capstone)
This is the final project, and deliberately the most "everything at once" of the five. We'll design a small community board — people post a short talk they're willing to give, and others browse and comment — built as a genuine two-part system: a Node.js server (Chapter 20) holding the shared state, and browser-side JavaScript (Chapters 14, 15, 18) rendering it and sending updates.
The shape of the system
Everything the server knows lives in memory for this project (no database) — the focus here is the shape of a full-stack app, not persistence, which you could layer on afterward using exactly the file-reading techniques from Chapter 20.
The server: modeling talks as a resource
Each talk has a title, a presenter, a summary, and a list of comments. The server exposes a small REST-style API over that data:
import { createServer } from 'node:http';
const talks = new Map(); // title -> { title, presenter, summary, comments: [] }
function sendJson(response, status, data) {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(data));
}
function readJsonBody(request) {
return new Promise((resolve, reject) => {
let body = '';
request.on('data', (chunk) => { body += chunk; });
request.on('end', () => {
try { resolve(JSON.parse(body || '{}')); }
catch (err) { reject(err); }
});
});
}
const server = createServer(async (request, response) => {
const url = new URL(request.url, `http://${request.headers.host}`);
if (request.method === 'GET' && url.pathname === '/talks') {
return sendJson(response, 200, [...talks.values()]);
}
if (request.method === 'POST' && url.pathname === '/talks') {
const { title, presenter, summary } = await readJsonBody(request);
if (!title || !presenter) return sendJson(response, 400, { error: 'title and presenter are required' });
talks.set(title, { title, presenter, summary, comments: [] });
return sendJson(response, 201, talks.get(title));
}
const commentMatch = url.pathname.match(/^\/talks\/([^/]+)\/comments$/);
if (request.method === 'POST' && commentMatch) {
const title = decodeURIComponent(commentMatch[1]);
const talk = talks.get(title);
if (!talk) return sendJson(response, 404, { error: 'No such talk' });
const { author, message } = await readJsonBody(request);
talk.comments.push({ author, message });
return sendJson(response, 201, talk);
}
sendJson(response, 404, { error: 'Not found' });
});
server.listen(8000, () => console.log('Skill-sharing server on http://localhost:8000'));
This readJsonBody helper is directly reused from Chapter 20's minimal server, just factored out since we now need it in three different route handlers — exactly the "notice repeated shape, extract a function" instinct from Chapter 3.
The client: fetching and rendering talks
On the browser side, the same state → render loop from Chapter 15 governs the whole page — fetch the current talks, render them into the DOM, and re-fetch after every change:
async function loadTalks() {
const response = await fetch('/talks');
const talks = await response.json();
renderTalks(talks);
}
function renderTalks(talks) {
const container = document.getElementById('talks');
container.textContent = ''; // clear previous render, Chapter 14
const fragment = document.createDocumentFragment();
for (const talk of talks) {
const section = document.createElement('section');
section.className = 'talk';
section.innerHTML = `
<h3>${escapeHtml(talk.title)}</h3>
<p><em>by ${escapeHtml(talk.presenter)}</em></p>
<p>${escapeHtml(talk.summary ?? '')}</p>
`;
// Comments are appended as real nodes, not string-interpolated —
// deliberately avoiding innerHTML for anything containing user text (Chapter 14).
const commentList = document.createElement('ul');
talk.comments.forEach((comment) => {
const li = document.createElement('li');
li.textContent = `${comment.author}: ${comment.message}`;
commentList.appendChild(li);
});
section.appendChild(commentList);
fragment.appendChild(section);
}
container.appendChild(fragment);
}
function escapeHtml(str) {
return str.replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
}
Notice the deliberate split: the parts of a talk we control (title, presenter, summary) go through innerHTML with manual escaping, while comment text — the most likely place for another user to type something adversarial — is built with textContent instead, sidestepping the risk entirely. This is the Chapter 14 innerHTML-vs-textContent warning, applied for real.
Submitting a new talk
document.getElementById('new-talk-form').addEventListener('submit', async (event) => {
event.preventDefault(); // Chapter 15
const data = new FormData(event.target); // Chapter 18
await fetch('/talks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: data.get('title'),
presenter: data.get('presenter'),
summary: data.get('summary'),
}),
});
event.target.reset();
await loadTalks(); // re-fetch so the new talk appears immediately
});
Keeping the page reasonably fresh
A genuinely "live" version of this page (where a comment from another visitor appears without a manual refresh) needs some way to be notified of changes — commonly WebSockets, or a simpler technique called long polling, where the client makes a request the server deliberately holds open until something changes, then immediately re-requests. Implementing that fully is a great next step once you're comfortable with the pieces above; for the version in this project, a simple periodic re-fetch is an honest, easy-to-understand starting point:
loadTalks();
setInterval(loadTalks, 5000); // poll for updates every 5 seconds
What this project — and this course — actually taught you
Look back at everything this one small app leaned on: objects and arrays modeling talks (Ch. 4), functions kept small and named (Ch. 3), a class-free but still structured server (Ch. 6's ideas, applied loosely), async/await throughout both server and client (Ch. 11), a hand-rolled HTTP server (Ch. 20), the DOM and safe rendering (Ch. 14), events and form handling (Ch. 15, 18), and even a regex for HTML-escaping (Ch. 9). A "full-stack app" isn't a separate skill from what you've been learning — it's these same fundamentals, composed.
Extend it yourself
- Add a
DELETE /talks/:titleroute and a delete button per talk on the client. - Replace the 5-second polling with real long polling: have the server hold
GET /talksopen (using a stored list of pending response objects) until a change happens, then respond to all of them at once. - Persist
talksto a JSON file on disk between server restarts, using thefs/promisestechniques from Chapter 20.
You've reached the end
That's all 21 chapters. Head back to the course overview to check off anything you skipped — once every chapter is marked complete, your certificate unlocks automatically.