javascript 90 lines · 3 tabs

Content Negotiation in Express: Serve JSON or CSV From One Route

Shared by codesnips Aug 2026
3 tabs
const express = require('express');
const { toCsv } = require('./csvSerializer');
const { fetchOrders } = require('./orderRepository');

const router = express.Router();

const ORDER_COLUMNS = ['id', 'customer', 'total', 'status', 'createdAt'];

router.get('/reports/orders', async (req, res, next) => {
  try {
    const orders = await fetchOrders({ since: req.query.since });

    res.format({
      'application/json': () => {
        res.json({ count: orders.length, data: orders });
      },

      'text/csv': () => {
        const csv = toCsv(orders, ORDER_COLUMNS);
        res.setHeader('Content-Type', 'text/csv; charset=utf-8');
        res.setHeader(
          'Content-Disposition',
          'attachment; filename="orders.csv"'
        );
        res.send(csv);
      },

      default: () => {
        res.status(406).json({
          error: 'Not Acceptable',
          supported: ['application/json', 'text/csv'],
        });
      },
    });
  } catch (err) {
    next(err);
  }
});

module.exports = router;
3 files · javascript Explain with highlit

Content negotiation lets a single URL return different representations of the same resource based on what the client asks for. In reportsRouter, the route GET /reports/orders is defined once, but the response body is chosen at runtime from the request's Accept header. This avoids duplicating routes like /orders.json and /orders.csv, keeps the resource identity stable, and follows the HTTP spec's intent that a URI names a resource while the media type names its representation.

The core mechanism is Express's res.format, which inspects Accept and dispatches to the matching callback. In reportsRouter, the handler first loads data via fetchOrders, then calls res.format with keys for application/json, text/csv, and a default. Each branch sets the correct Content-Type automatically and, for CSV, adds Content-Disposition so browsers download a sensibly named file. When no branch matches, Express calls the default handler, which is written here to send a proper 406 Not Acceptable rather than silently guessing.

The CSV serialization lives in csvSerializer to keep the route thin and the formatting testable. toCsv builds a header row from an explicit columns list — relying on object key order is fragile — and escapes every field through escapeCell. That escaping is the part people get wrong: any cell containing a comma, quote, or newline must be wrapped in double quotes with internal quotes doubled, per RFC 4180. It also guards against CSV injection by prefixing values that begin with =, +, -, or @, which spreadsheet apps would otherwise interpret as formulas.

The app setup tab wires everything together and shows a subtle but important detail: express.json() is a body parser and has nothing to do with response negotiation, while ordering the router before the error handler matters. A trade-off of res.format is that it matches on media type only, not on quality-weighted preferences beyond Express's built-in req.accepts logic, so highly nuanced negotiation may need manual req.accepts() calls. For most APIs, though, this pattern cleanly serves machine clients JSON and analysts CSV from one endpoint, with correct status codes and headers.


Related snips

Share this code

Here's the card — post it anywhere.

Content Negotiation in Express: Serve JSON or CSV From One Route — share card
Link copied