Imagine you type a blog post on your school's class website — "Book Review: Chandra Shekhar Azad's Life" — hit Publish, close the laptop, and switch it off. The next morning, a classmate opens the site on a completely different computer and your post is right there. Nothing on your laptop is running anymore. So where was that text actually sitting all night? It was never "in the browser" at all — a browser tab is like a whiteboard that gets wiped clean the moment it closes. For your words to survive after your machine shuts down and to be visible on someone else's machine, they have to travel somewhere else entirely: to a computer that never sleeps, which stores them somewhere permanent. That journey — from a text box in your browser, across the internet, into a running program, and finally into a stored file — is what "full stack" means, and building the smallest possible working version of that journey is the goal of this chapter.
What "Full Stack" Actually Means
A "stack" here does not mean a pile of books. It means layers of software stacked on top of each other, each doing one job, that together make an application work end to end. A full-stack project has exactly three layers, and a working blog needs all three — leave one out and the app breaks in a specific, predictable way.
The frontend is the part that runs inside the user's browser: the HTML that draws the page, and the JavaScript that reacts to clicks and typing. The frontend cannot remember anything permanently — refresh the page and every JavaScript variable is gone. The backend is a program that runs continuously on a server (a computer that stays on), listens for requests arriving over the network, and decides what to do with each one — fetch something, save something, reject something. The database is where information is actually kept in a durable, organized form, on disk, so it survives even if the backend program itself is restarted. Full stack, then, is simply: frontend talks to backend, backend talks to database, and the whole round trip has to work correctly before a single blog post can survive overnight.
The Three-Tier Architecture: Browser, Server, Database
Before writing a single line of code, it helps to see the shape of the system, because every bug you will ever hit in this project is really a question of "which of these three arrows failed?" The diagram below traces exactly what happens when you publish one post — the same example we will build and trace in code through the rest of this chapter.
Notice the dashed red line at the top: that connection does not exist in a real full-stack app, and it is drawn only to mark what is missing. The browser cannot open blog.db itself, cannot run SQL, and — this matters for security — should never even know the database exists. Every single thing the frontend wants (see all posts, publish a new one, edit one, delete one) has to be phrased as an HTTP request to the backend, and the backend is the only program allowed to touch the database file. This is not an arbitrary rule; it is what makes the system safe and organized. If browsers could talk to databases directly, every visitor to your blog would need a working password to your database, and anyone with basic tools could rewrite or delete every post you ever wrote.
Designing the Database: One Table Is Enough to Start
A blog, at its core, is a list of posts, and each post needs an identity, a title, and a body of text. In SQL — the language used to describe and query tables — that becomes one table with four columns:
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
Read this the way you would read a table with column headings. id is declared INTEGER PRIMARY KEY AUTOINCREMENT — SQLite will hand out 1, 2, 3, 4… automatically as new rows are inserted, and no two rows can ever share an id, because "primary key" means "the unique label for this row." title and body are TEXT, and NOT NULL means the database itself will refuse to store a post with a missing title or missing body — that rule is enforced by the database, not just by your JavaScript, which matters because it is your last line of defence if a bug ever lets bad data slip past the backend. created_at defaults to the current timestamp automatically, so you get a "posted on" date for free, without your code ever computing today's date itself.
CRUD: The Four Things Every App Needs to Do to Data
Every application that stores information — a blog, a to-do list, an IRCTC ticket booking system, a school's attendance register — needs exactly four operations on that data, known by the acronym CRUD:
- Create — add a new row (publish a new post). Mapped to the HTTP method
POST. - Read — fetch existing rows (view the post list, or one post). Mapped to
GET. - Update — change an existing row (edit a post you already published). Mapped to
PUT. - Delete — remove a row (take a post down). Mapped to
DELETE.
These four HTTP methods are not arbitrary labels — they are a contract. When a request arrives at a server tagged GET, that request is a promise from the browser that it is only asking to look at data, never to change it; a server is allowed to assume a GET is safe to repeat many times (which is exactly why your browser can safely reload a page). POST, PUT, and DELETE all change stored data, so browsers never trigger them just by loading a page — they only fire when your JavaScript deliberately sends one. Designing an API means deciding, for every action your app supports, which of these four verbs it is, and which URL it lives at. Our blog's whole API surface is five routes: list all posts, read one post, create a post, update a post, delete a post.
Building the Backend: server.js, Route by Route
The backend is a Node.js program using the Express framework to listen for HTTP requests, and the better-sqlite3 library to talk to the database file. Here is the complete server:
const express = require('express');
const Database = require('better-sqlite3');
const app = express();
app.use(express.json());
const db = new Database('blog.db');
db.exec(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
app.get('/api/posts', function (req, res) {
const posts = db.prepare('SELECT * FROM posts ORDER BY id DESC').all();
res.json(posts);
});
app.get('/api/posts/:id', function (req, res) {
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
res.json(post);
});
app.post('/api/posts', function (req, res) {
const title = req.body.title;
const body = req.body.body;
if (!title || !body) {
return res.status(400).json({ error: 'Title and body are required' });
}
const result = db.prepare('INSERT INTO posts (title, body) VALUES (?, ?)').run(title, body);
res.status(201).json({ id: result.lastInsertRowid, title: title, body: body });
});
app.put('/api/posts/:id', function (req, res) {
const title = req.body.title;
const body = req.body.body;
const result = db.prepare('UPDATE posts SET title = ?, body = ? WHERE id = ?')
.run(title, body, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Post not found' });
}
res.json({ id: Number(req.params.id), title: title, body: body });
});
app.delete('/api/posts/:id', function (req, res) {
const result = db.prepare('DELETE FROM posts WHERE id = ?').run(req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Post not found' });
}
res.status(204).end();
});
app.listen(3000, function () {
console.log('Blog server running on port 3000');
});
Two lines near the top set up the plumbing everything else depends on. app.use(express.json()) tells Express to automatically read the raw bytes of any incoming request body and parse them as JSON, placing the result in req.body — without this line, req.body would be undefined and every POST and PUT route above would crash. db.exec(...) runs the CREATE TABLE IF NOT EXISTS statement once when the server starts, which means you can delete blog.db entirely and the very next server start will recreate an empty, correctly-shaped table — a cheap safety net while you are learning.
Each route follows the same three-step shape: read something out of the request (req.params for values embedded in the URL, like :id; req.body for the JSON payload), run exactly one prepared SQL statement against the database, and send back a response with an HTTP status code that honestly describes what happened. db.prepare(sql) compiles the SQL once; calling .get(...) on it returns a single row or undefined, .all(...) returns every matching row as an array, and .run(...) is used for statements that change data (INSERT, UPDATE, DELETE) and gives back an object reporting how many rows were touched (changes) and, for inserts, the new row's id (lastInsertRowid).
Worked Example: Publishing One Post, Traced Step by Step
Suppose the frontend sends this request to create the very first post on an empty blog:
POST /api/posts
Content-Type: application/json
{"title": "My First Post", "body": "Hello, Grade 8!"}
Trace it exactly as the server would execute it. First, express.json() parses the body, so req.body becomes the object { title: "My First Post", body: "Hello, Grade 8!" }. Inside the app.post('/api/posts', ...) handler, title is set to the string "My First Post" and body to "Hello, Grade 8!" — both are truthy strings, so the if (!title || !body) guard is false and execution continues past it. The prepared statement runs INSERT INTO posts (title, body) VALUES (?, ?) with the two values bound in place of the two ? placeholders (this is called a parameterized query, and it is the standard way to insert user-supplied text safely — never build SQL by pasting strings together, since a title containing a stray quote character could otherwise corrupt the statement). Because the table was empty, SQLite assigns this new row id = 1 automatically. result.lastInsertRowid is therefore 1, and result.changes is 1. The response sent back is:
HTTP/1.1 201 Created
{"id": 1, "title": "My First Post", "body": "Hello, Grade 8!"}
Status 201 specifically means "a new resource was created," which is more informative than a plain 200 OK — it tells the frontend, without it having to guess, that this response contains a brand-new row it did not have before.
Reading, Editing, and Deleting: Tracing the Rest of the Cycle
Now trace what happens when the frontend asks to view that exact post: GET /api/posts/1. Express extracts the URL segment after /api/posts/ and places it in req.params.id — but note carefully, it places it there as the string "1", not the number 1, because everything in a URL is text. The handler runs SELECT * FROM posts WHERE id = ? with "1" bound to the placeholder. This still finds the correct row (explained in the next section), returns the full stored object including created_at, and sends it back with the default status 200 OK since res.json(post) was called without an explicit status.
Editing works the same way, one step further: PUT /api/posts/1 with body {"title": "My First Post (Edited)", "body": "Hello, Grade 8! Updated."}. The handler runs UPDATE posts SET title = ?, body = ? WHERE id = ?, binding the new title, new body, and "1" in that order — the order of the ? placeholders must exactly match the order of arguments passed to .run(), since SQLite fills them left to right. One row's title and body change; result.changes becomes 1, so the 404 branch is skipped, and the response is {"id": 1, "title": "My First Post (Edited)", "body": "Hello, Grade 8! Updated."}. Notice this route wraps the id in Number(req.params.id) before sending it back — that is a deliberate choice to hand the frontend a proper JavaScript number rather than a string that merely looks like one, since the frontend may later want to compare or sort by id numerically.
Deleting is the simplest: DELETE /api/posts/1 runs DELETE FROM posts WHERE id = ? bound to "1", removes the row, and — since there is no content left to describe — responds with status 204 No Content and an empty body via res.status(204).end(). If the same request were sent a second time, the row would no longer exist, result.changes would be 0, and the route would correctly respond 404 Post not found instead of silently pretending to succeed.
A Subtlety Worth Understanding: Why req.params.id Works Even Though It's Text
Look again at every route above that touches a specific post: the value coming from the URL, req.params.id, is always a JavaScript string like "1", yet it is compared directly against the id column, which was declared INTEGER PRIMARY KEY. It is a reasonable question to ask whether this comparison actually works, or whether the string "1" silently fails to match the stored integer 1. It does work, and the reason is a SQLite rule called type affinity: when a column is declared with INTEGER affinity and it is compared to a value that arrived with TEXT affinity, SQLite converts the text side to a number before comparing, provided the text genuinely looks like a number. So WHERE id = ? bound to "1" is silently treated as WHERE id = 1, and the row matches correctly — you do not strictly need to write Number(req.params.id) before using it inside a SQL comparison.
That said, relying on this silent conversion everywhere is not the most disciplined habit, for a reason separate from correctness of the SQL itself: if someone requests GET /api/posts/banana, the string "banana" cannot be converted to a number at all, so SQLite falls back to a plain text comparison, finds no row whose id equals the text "banana", and the route correctly returns 404 Post not found. That happens to be the right outcome here, but it arrives by accident rather than by design — a more careful version of this route would check with Number.isInteger(Number(req.params.id)) first and return a clear 400 Bad Request ("id must be a number") for junk input, rather than letting an unrelated 404 stand in for a different kind of error. Knowing that SQLite's affinity rules are doing quiet work behind the scenes is exactly the kind of "why does this even work" detail that separates understanding a full-stack app from merely copying one.
Building the Frontend: Talking to the API with fetch()
The frontend's job is to turn the five backend routes into something a person can click. The browser's built-in fetch() function sends an HTTP request and returns a Promise that resolves to the response:
async function loadPosts() {
const res = await fetch('/api/posts');
const posts = await res.json();
const list = document.getElementById('post-list');
list.innerHTML = posts.map(function (p) {
return '<li><strong>' + p.title + '</strong>: ' + p.body + '</li>';
}).join('');
}
async function createPost(title, body) {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title, body: body })
});
const newPost = await res.json();
console.log('Created post with id', newPost.id);
loadPosts();
}
Trace loadPosts() first: fetch('/api/posts') defaults to a GET request, which lands on the app.get('/api/posts', ...) route and comes back as a JSON array of every post. await res.json() parses that array into a real JavaScript array of objects, and .map() turns each post object into a small string of HTML, which .join('') stitches into one block written into the page via innerHTML. Now trace createPost('My First Post', 'Hello, Grade 8!'): this time fetch is given an options object specifying method: 'POST', a header telling the server the body is JSON, and a body built with JSON.stringify — which converts the JavaScript object { title, body } into the exact string '{"title":"My First Post","body":"Hello, Grade 8!"}' that travels over the network. This is precisely the request traced earlier in the worked example, and after the server answers, createPost calls loadPosts() again so the newly published post appears on screen immediately, without the user manually refreshing the page. This refresh-without-reloading behaviour — send data, then re-fetch and redraw — is the core rhythm of essentially every interactive web app you will ever build.
Common Misconception: "The Browser Talks to the Database"
A very natural mistake, especially for a first full-stack project, is to imagine the browser is somehow directly reading and writing blog.db. It is worth stating precisely why this is wrong, because the confusion usually comes from the fact that, from a user's point of view, clicking Publish really does feel instantaneous and direct. In reality, three separate programs are involved, running in three separate places, and the browser's JavaScript never once mentions the word "SQL" or the filename blog.db anywhere in createPost. All the browser knows how to do is send an HTTP request to a URL and read back JSON. Only server.js, running on the server machine, imports better-sqlite3 and is capable of opening the database file at all. This separation is not incidental — it is the entire reason full-stack architecture is organized into layers in the first place: the browser is untrusted (anyone can open their browser's developer tools and rewrite the JavaScript running there), so no code that is allowed to run inside someone else's browser is ever given a direct line to your permanent data. Every request, no matter how it originated, must pass through the backend's checks — the if (!title || !body) guard, the 404 checks, the parameterized queries — before it can touch a single row.
Running the Project
To actually run this, you would create a folder with server.js, an index.html containing the frontend markup and a <script> with the fetch functions above, install the two dependencies with npm install express better-sqlite3, and start the server with node server.js. Visiting http://localhost:3000 in a browser then exercises the full loop this chapter traced: your click becomes a fetch call, becomes an HTTP request, becomes a SQL statement, becomes a row on disk, and comes back as JSON that redraws the page — the same round trip, five different route shapes, one file surviving on disk long after the browser tab that created it is gone.
Test Your Tracing Skills
- The
poststable currently has one row withid = 1. A request arrives:PUT /api/posts/2with body{"title": "X", "body": "Y"}. Trace the handler and state the exact HTTP status code and JSON body sent back, and explain which line of the code produces that outcome. - Someone removes the line
app.use(express.json())fromserver.jsbut leaves everything else unchanged. Trace what happens inside theapp.post('/api/posts', ...)handler when a normal create-post request arrives, and explain precisely which line now behaves differently and why. - A student argues that the
ORDER BY id DESCin theGET /api/postsroute is unnecessary because SQLite will naturally return rows "in the order they were created" anyway. Is it safe to removeORDER BY id DESCand still guarantee newest-first order? Justify your answer in terms of what a database is and is not obligated to guarantee. - Explain, using the specific SQLite affinity rule from this chapter, why
db.prepare('SELECT * FROM posts WHERE id = ?').get('7')correctly returns the post withid = 7even though'7'is a string, not a number. - A new route is proposed:
app.get('/api/posts/search', ...), placed in the code after the existingapp.get('/api/posts/:id', ...)route. Predict what actually happens when a request for/api/posts/searcharrives, given that Express matches routes in the order they are registered, and explain why route order matters here.
Summary
- A full-stack application has three layers: the frontend (browser, HTML/JS, remembers nothing between reloads), the backend (a continuously running server program that decides what each request is allowed to do), and the database (permanent storage on disk, outliving both the browser tab and any single server restart).
- The frontend never touches the database directly; it can only send HTTP requests to the backend, and the backend is the sole gatekeeper to the data — this separation is a security and reliability design, not an accident.
- CRUD (Create, Read, Update, Delete) maps cleanly onto the HTTP methods POST, GET, PUT, and DELETE; a working blog API is exactly five routes built from this mapping.
- A minimal schema —
id INTEGER PRIMARY KEY AUTOINCREMENT,title TEXT NOT NULL,body TEXT NOT NULL,created_at TEXT DEFAULT CURRENT_TIMESTAMP— is enough to store and enforce the shape of every blog post, with the database itself refusing invalid rows. - Every backend route follows the same shape: read from
req.params/req.body, run one parameterized SQL statement via.get(),.all(), or.run(), and respond with a status code that honestly reflects what happened (200, 201, 204, 400, or 404). - URL parameters like
req.params.idalways arrive as strings; SQLite's type affinity rules convert them for numeric comparison automatically, but explicit validation is still the more disciplined habit when the input might not be a number at all.