Content negotiation
How clients and servers agree on format
Every HTTP request and response carries data in some format. Usually it’s JSON. But sometimes a client needs CSV to import into a spreadsheet, or you need to verify that the client is actually sending JSON and not something else. The mechanism for this is called content negotiation, and it’s built into HTTP through two headers.
Content-Type and Accept
Content-Type on a request tells the server what format the request body is in. When a client sends Content-Type: application/json, it’s saying “the body I’m sending you is JSON.”
Accept on a request tells the server what format the client wants the response in. When a client sends Accept: application/json, it’s saying “please send me JSON back.”
Content-Type on the response tells the client what format the response body is in. When Response.json() sends data, it automatically sets Content-Type: application/json.
JSON is the default
For REST APIs, JSON is the standard. If no Accept header is sent, return JSON. Most clients assume JSON. Most tooling expects JSON. Unless you have a specific reason to support other formats, JSON is all you need.
import { books } from "../db.js";
route.get("/books/:id", {
request: { params: z.object({ id: z.string() }) },
resolve: (c) => {
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { id } = c.input.params;
const book = books.find((b) => b.id === id);
if (!book) return notFound("Book");
return Response.json(book); // Content-Type: application/json
},
}); That’s it. For many APIs, content negotiation starts and ends with “return JSON.”
Supporting multiple formats
Sometimes you need more than JSON. The most common case is CSV export. Users want to download data into a spreadsheet. Let’s add CSV support to our books endpoint:
import { books } from "../db.js";
route.get("/books", {
resolve: (c) => {
const accept = c.request.headers.get("accept") ?? "application/json";
if (accept.includes("text/csv")) {
const csv = booksToCSV(books);
return new Response(csv, {
headers: { "content-type": "text/csv" },
});
}
// Default: JSON
return Response.json({ data: books });
},
});
function booksToCSV(books: any[]): string {
const header = "id,title,genre,publishedAt,authorId";
const rows = books.map(
(b) => `${b.id},"${b.title}",${b.genre},${b.publishedAt ?? ""},${b.authorId}`,
);
return [header, ...rows].join("\n");
} The server checks the Accept header. If the client wants CSV, it converts the books to CSV format and sets the response Content-Type to text/csv. Otherwise, it returns JSON as usual.
The client controls this entirely through the Accept header:
# Get JSON (default)
curl http://localhost:3000/books
# Get CSV
curl -H "Accept: text/csv" http://localhost:3000/books 406 Not Acceptable
What if the client requests a format you don’t support? Like XML?
if (
!accept.includes("application/json") &&
!accept.includes("text/csv") &&
!accept.includes("*/*")
) {
return apiError(406, "NOT_ACCEPTABLE", "Supported formats: application/json, text/csv");
} 406 Not Acceptable means “I can’t produce a response in any format you’ll accept.” The response should tell the client which formats are available.
In practice, if your API only supports JSON, you don’t need to check the Accept header at all. Just return JSON. The 406 check is only worth adding when you support multiple formats and want to be explicit about what’s available.
Validating request body format
On the flip side, your server should verify that incoming request bodies are in the right format. If an endpoint expects JSON but receives form data or plain text, that’s a problem:
const contentType = c.request.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return apiError(415, "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json");
} 415 Unsupported Media Type means “I can’t process the format you sent me.”
[!NOTE] Hectoday HTTP’s Zod body validation handles this implicitly. If the body can’t be parsed as JSON, the validation fails with a clear error. You only need explicit Content-Type checking if you want to return a more specific 415 error message before attempting to parse.
When to support multiple formats
Support JSON only for most APIs. JSON is universal, every language has a parser for it, and every developer expects it.
Add CSV for data export endpoints. When users need to pull data into spreadsheets or import it into other tools, CSV is the right choice.
Add XML only for legacy integrations. Some enterprise systems only speak XML. But don’t add XML support “just in case.” Wait until someone actually needs it.
What’s next
We’ve been building features: filtering, pagination, field selection, content negotiation. But we haven’t thought about protecting the API from abuse. What happens if one client sends thousands of requests per second? That’s where rate limiting comes in.
Exercises
Exercise 1: Add CSV support to GET /books. Test with curl -H "Accept: text/csv" http://localhost:3000/books.
Exercise 2: Add a 406 response when the client requests an unsupported format (like Accept: text/xml).
Exercise 3: Add Content-Type validation to POST endpoints. Return 415 for non-JSON request bodies.
What should an API return when the client sends Accept: text/xml but the API only supports JSON?