Series: Pure Node.js — Zero Dependencies

Post #4: Handling Forms & POST Requests

📅 March 2026 ⏱ 9 min read 🏷 Node.js, Forms, HTTP, POST

📚 Pure Node.js Series — Zero Dependencies

#1: Build an HTTP Server From Scratch #2: Manual Routing — No Express Needed #3: Serving Static Files (HTML, CSS, JS) → #4: Handling Forms & POST Requests (you are here) #5: JSON Files as a Database #6: HTML Templating with String Interpolation #7: Vanilla JS Charts & Dynamic UI #8: WebSockets From Scratch #9: Chat Rooms & Broadcast Messages #10: Sessions & Cookies Without Packages #11: File Uploads & Image Serving #12: Deploy Pure Node.js to a VPS

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();
}
PRG Pattern (Post/Redirect/Get): Always redirect after a successful POST. This prevents the browser from resubmitting the form if the user refreshes the page. Use HTTP 303 See Other for this.

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;
}
Key takeaways:
  • Request bodies arrive as streams — listen for data and end events
  • Use URLSearchParams to 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.

Building along? Share your code on X/Twitter or GitHub.