The Problem With POST Requests
GET requests are simple — the data is in the URL. But POST requests carry data in the request body, and that body arrives as a stream of chunks. This is one area where Express's body-parser middleware quietly does a lot of work for you. In this post, we do it ourselves.
How HTTP Bodies Work
When a browser submits a form, the data is sent in the request body as a stream. Node.js gives you that stream via events on the req object — you listen for data events to collect chunks, then the end event when all data has arrived.
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(chunk);
// Security: limit body size to 1MB
if (chunks.reduce((acc, c) => acc + c.length, 0) > 1_000_000) {
req.destroy();
reject(new Error('Payload too large'));
}
});
req.on('end', () => {
resolve(Buffer.concat(chunks).toString());
});
req.on('error', reject);
});
}
Parsing Form Data
When an HTML form uses method="POST" and no enctype, the browser sends data as application/x-www-form-urlencoded — a format like title=Buy+milk&done=false. We can parse this with the built-in URLSearchParams:
// Handler for POST /todos
async function handleCreateTodo(req, res) {
const contentType = req.headers['content-type'] || '';
const body = await readBody(req);
let title = '';
if (contentType.includes('application/x-www-form-urlencoded')) {
// Parse: "title=Buy+milk&priority=high"
const params = new URLSearchParams(body);
title = params.get('title') || '';
} else if (contentType.includes('application/json')) {
// Parse JSON body from fetch() calls
const data = JSON.parse(body);
title = data.title || '';
}
// Validate
if (!title.trim()) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Title is required' }));
return;
}
// Create the todo (we'll persist in Post #5)
const todo = {
id: Date.now().toString(),
title: title.trim(),
done: false,
createdAt: new Date().toISOString()
};
// Redirect after POST (PRG pattern)
res.writeHead(303, { Location: '/todos' });
res.end();
}
The Full HTML Form
Here's the form that sends data to our handler:
<!-- static/index.html -->
<form action="/todos" method="POST">
<input
type="text"
name="title"
placeholder="What needs to be done?"
required
maxlength="200"
/>
<button type="submit">Add Todo</button>
</form>
Connecting It to the Router
// router.js — add these routes
router.add('POST', '/todos', handleCreateTodo);
router.add('POST', '/todos/:id/delete', handleDeleteTodo);
router.add('POST', '/todos/:id/toggle', handleToggleTodo);
Handling DELETE and PATCH via POST
HTML forms only support GET and POST — not DELETE or PATCH. A common pattern is to use a hidden _method field to simulate other HTTP methods:
<!-- Delete form -->
<form action="/todos/42/delete" method="POST">
<button type="submit">Delete</button>
</form>
<!-- Toggle done -->
<form action="/todos/42/toggle" method="POST">
<button type="submit">Mark Done</button>
</form>
Accepting JSON from fetch()
Our API should also accept JSON for JavaScript clients:
// Frontend JavaScript (no frameworks)
async function addTodo(title) {
const res = await fetch('/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error);
}
return res.json();
}
Input Validation
Never trust user input. Here's a simple but effective validation helper:
function validateTodo(data) {
const errors = [];
if (!data.title || !data.title.trim()) {
errors.push('Title is required');
}
if (data.title && data.title.length > 200) {
errors.push('Title must be 200 characters or less');
}
return errors;
}
// Usage in handler
const errors = validateTodo({ title });
if (errors.length > 0) {
res.writeHead(422, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ errors }));
return;
}
- Request bodies arrive as streams — listen for
dataandendevents - Use
URLSearchParamsto parse form data — no packages needed - Use
JSON.parse()to parse JSON bodies from fetch() calls - Always redirect after POST (PRG pattern) — prevents duplicate submissions
- Limit body size — always protect against payload flooding attacks
What's Next
In Post #5, we persist todos to a JSON file — building a proper file-based database with create, read, update, and delete operations using only fs.promises.