Serving JSON Responses
JSON is the lingua franca of web APIs — the format your server uses to hand structured data to browsers, mobile apps, and other services. Serving it well is more than JSON.stringify: you need the right Content-Type, correct status codes, safe serialization, and a consistent shape so clients can rely on your responses. This page covers building a clean JSON API on raw http.
The basics
import { createServer } from 'node:http'
createServer((req, res) => {
const data = { id: 1, name: 'Ada', active: true }
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(data)) // serialize before sending
}).listen(3000)A reusable JSON helper
Repeating writeHead + stringify everywhere invites mistakes. Wrap it once:
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload)
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body), // bytes, not characters
})
res.end(body)
}
// Usage:
sendJson(res, 200, { users: [] })
sendJson(res, 404, { error: 'Not Found' })
sendJson(res, 201, { id: 42 })Reading a JSON request body
Most APIs also accept JSON (POST/PUT). You must read the streamed body and parse it — guarding both size and parse errors:
async function readJson(req, limit = 1e6) {
let size = 0
const chunks = []
for await (const chunk of req) {
size += chunk.length
if (size > limit) throw Object.assign(new Error('Payload too large'), { status: 413 })
chunks.push(chunk)
}
const raw = Buffer.concat(chunks).toString('utf8')
if (!raw) return {}
try {
return JSON.parse(raw)
} catch {
throw Object.assign(new Error('Invalid JSON'), { status: 400 })
}
}A small JSON API endpoint
import { createServer } from 'node:http'
const users = new Map([[1, { id: 1, name: 'Ada' }]])
createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
try {
if (req.method === 'GET' && url.pathname === '/users') {
return sendJson(res, 200, [...users.values()])
}
if (req.method === 'POST' && url.pathname === '/users') {
const body = await readJson(req)
if (!body.name) return sendJson(res, 422, { error: 'name is required' })
const id = users.size + 1
users.set(id, { id, name: body.name })
return sendJson(res, 201, { id, name: body.name })
}
sendJson(res, 404, { error: 'Not Found' })
} catch (err) {
sendJson(res, err.status || 500, { error: err.message })
}
}).listen(3000)Consistent response shape
Clients are far easier to write when every response follows a predictable structure. Pick a convention and stick to it — for example, a discriminator plus payload:
// Success:
{ "data": { "id": 1, "name": "Ada" } }
// Error:
{ "error": { "code": "VALIDATION", "message": "name is required" } }
// Collection with pagination metadata:
{ "data": [/* ... */], "meta": { "page": 1, "total": 57 } }Serialization gotchas
Value | JSON.stringify result |
|---|---|
| Key is omitted |
| Returns |
| Becomes |
| ISO string (via |
| Throws a TypeError |
|
|
Circular reference | Throws a TypeError |