Isaac.

Implement Content Negotiation

A practical, framework-agnostic guide with examples for Express (Node.js), ASP.NET Core, and Spring Boot. Dark-theme TSX, blog-ready, and safely escaped snippets.

Introduction

Content negotiation lets your API serve the same resource in different representations (e.g., JSON, XML, HTML) based on the client's request. This improves interoperability, forwards compatibility, and developer experience without duplicating endpoints.

TL;DR: Look at Accept, pick a supported media type, serialize accordingly, set Content-Type, or return 406 Not Acceptable if you can't.

What Is Content Negotiation?

It's the process where the client states preferred representations (via HTTP headers) and the server selects the best match. The most common header is Accept (e.g., application/json, application/xml, text/html), but others like Accept-Language and Accept-Encoding also participate.

How It Works (Headers & Status Codes)

Key Request Headers

  • Accept: Preferred media types with optional quality weights (e.g., application/json; q=0.9, application/xml; q=0.5).
  • Accept-Language: Preferred languages (e.g., en-GB, fr;q=0.8).
  • Content-Type: Media type of the request body (for POST/PUT/PATCH).

Typical Responses

  • 200 OK with Content-Type that matches what you chose.
  • 406 Not Acceptable when no supported representation matches Accept.
  • 415 Unsupported Media Type when the request body type isn't supported.

Media Types & Versioning

Prefer standard types like application/json. For versioning, many teams use vendor-specific types such as application/vnd.example.v2+json. This keeps your URL stable while allowing schema evolution.

# Example Accept header for versioning
Accept: application/vnd.example.v2+json, application/json; q=0.8

Language Negotiation

Use Accept-Language to select localized strings or resources. Always specify a default language and echo the chosen value in Content-Language.

# Request
GET /greeting HTTP/1.1
Accept-Language: en-GB, fr;q=0.8

# Response
HTTP/1.1 200 OK
Content-Type: application/json
Content-Language: en-GB

Code Examples

ASP.NET Core

Out of the box, ASP.NET Core negotiates based on registered formatters. You can restrict/advertise outputs with attributes:

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers().AddXmlSerializerFormatters();
var app = builder.Build();
app.MapControllers();
app.Run();

// UsersController.cs
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase {
    [HttpGet("{id}")]
    [Produces("application/json", "application/xml", "text/html")]
    public IActionResult GetUser(int id) {
        var user = new { Id = id, Name = "Ada Lovelace" };
        var accept = Request.GetTypedHeaders().Accept?.ToString() ?? string.Empty;

        if (accept.Contains("text/html"))
            return Content($"<h1>User {user.Name}</h1>", "text/html");

        // JSON/XML handled by formatters automatically
        return Ok(user);
    }
}

Spring Boot

Use produces on mappings and let HttpMessageConverters serialize:

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-web'

// UserController.java
@RestController
@RequestMapping("/users")
public class UserController {
  @GetMapping(value = "/{id}", produces = {"application/json", "application/xml", "text/html"})
  public ResponseEntity<?> get(@PathVariable String id, @RequestHeader(value = "Accept", required = false) String accept) {
    var user = Map.of("id", id, "name", "Ada Lovelace");
    if (accept != null && accept.contains("text/html")) {
      return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body("<h1>User Ada Lovelace</h1>");
    }
    // JSON/XML are auto-converted when appropriate converters are on classpath
    return ResponseEntity.ok(user);
  }
}

Returning 406/415 Correctly

// Express: 415 Unsupported Media Type for request bodies
app.post('/users', (req, res) => {
  if (!req.is('application/json')) {
    return res.status(415).json({ success: false, errors: ['Unsupported Media Type'] });
  }
  // ... parse body & create
});

// Spring: 406 when no acceptable type
@GetMapping(value = "/data", produces = {"application/json"})
public ResponseEntity<String> onlyJson(HttpServletRequest request) {
  var accept = request.getHeader("Accept");
  if (accept != null && !accept.contains("application/json")) {
    return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body("Not Acceptable");
  }
  return ResponseEntity.ok("{\"ok\":true}");
}

Testing with curl

# Ask for JSON
curl -H "Accept: application/json" https://api.example.com/users/1

# Ask for XML
curl -H "Accept: application/xml" https://api.example.com/users/1

# Ask for HTML
curl -H "Accept: text/html" https://api.example.com/users/1

# Send JSON body (Content-Type) and ask for v2 JSON
curl -X POST   -H "Content-Type: application/json"   -H "Accept: application/vnd.example.v2+json"   -d '{"name":"Ada"}'   https://api.example.com/users

# Trigger 406 (no supported type)
curl -H "Accept: application/yaml" https://api.example.com/users/1 -i

# Language preference
curl -H "Accept-Language: fr, en;q=0.9" https://api.example.com/greeting

Best Practices

  • Pick a sensible default (usually application/json) when Accept is missing.
  • Return 406 only when the client explicitly asked for unsupported types.
  • Always set the chosen Content-Type on the response.
  • Keep representation schemas consistent per media type and version.
  • Document supported media types and language tags in your API docs.
  • Automate tests that assert negotiation behavior.

💡 Pro Tip

Use vendor media types for versioning (application/vnd.yourco.vN+json) and deprecate older versions gradually with Sunset and Deprecation headers.

Common Mistakes

  • Ignoring Accept and always returning JSON (breaks strict clients).
  • Not advertising supported types in docs or via OPTIONS/OpenAPI.
  • Mixing request Content-Type validation with response negotiation logic.
  • Returning 200 with the wrong Content-Type.
  • Falling back to a type the client explicitly disallowed (e.g., q=0).

Conclusion

Content negotiation is a small amount of extra work with outsized benefits: happier clients, smoother versioning, and clearer contracts. Start by honoring Accept, returning correct Content-Type, and handling 406/415 properly—then layer on language and versioning as your API grows.