nestjs/nest · warning

Content-Type doesn't match Reply body, you might need a cust

Error message

Content-Type doesn't match Reply body, you might need a custom ExceptionFilter for non-JSON responses

What it means

FastifyAdapter.reply() performs the same consistency check as the Express adapter: if fastifyReply already has a Content-Type header that is not exactly 'application/json' while the body has statusCode >= 400, it warns and overrides the header to application/json before send(body). Note the Fastify check is exact equality, so even 'application/json; charset=utf-8' triggers it. The warning means a non-JSON content type was set on a response that ultimately delivers a JSON error body (usually the built-in exception filter's payload).

Source

Thrown at packages/platform-fastify/adapters/fastify-adapter.ts:489

        fastifyReply.getHeader('Content-Disposition') === undefined &&
        streamHeaders.disposition !== undefined
      ) {
        fastifyReply.header('Content-Disposition', streamHeaders.disposition);
      }
      if (
        fastifyReply.getHeader('Content-Length') === undefined &&
        streamHeaders.length !== undefined
      ) {
        fastifyReply.header('Content-Length', streamHeaders.length);
      }
      body = body.getStream();
    }
    if (
      fastifyReply.getHeader('Content-Type') !== undefined &&
      fastifyReply.getHeader('Content-Type') !== 'application/json' &&
      body?.statusCode >= HttpStatus.BAD_REQUEST
    ) {
      Logger.warn(
        "Content-Type doesn't match Reply body, you might need a custom ExceptionFilter for non-JSON responses",
        FastifyAdapter.name,
      );
      fastifyReply.header('Content-Type', 'application/json');
    }
    return fastifyReply.send(body);
  }

  public status(response: TRawResponse | TReply, statusCode: number) {
    if (this.isNativeResponse(response)) {
      response.statusCode = statusCode;
      return response;
    }
    return (response as { code: Function }).code(statusCode);
  }

  public end(response: TReply, message?: string) {
    response.raw.end(message!);

View on GitHub (pinned to 3f8a0ce183)

Solutions

  1. Set Content-Type only right before sending the successful body; do risky work first.
  2. Custom ExceptionFilters must write the whole response themselves — reply.code(status).header('Content-Type', matching).send(body) — never set headers and delegate to the built-in filter.
  3. If you set 'application/json; charset=utf-8' manually, drop the charset suffix or let Fastify set JSON content type itself.
  4. Assert error-response Content-Type in e2e tests to catch header/error-path drift.

Example fix

// before
@Get('report')
exportReport(@Res() reply: FastifyReply) {
  reply.header('Content-Type', 'text/csv');
  throw new InternalServerErrorException('boom'); // JSON error body + text/csv header -> warning
}

// after
@Get('report')
async exportReport(@Res() reply: FastifyReply) {
  const csv = await buildReport(); // risky work first
  reply.header('Content-Type', 'text/csv');
  reply.send(csv);
}
Defensive patterns

Strategy: validation

Validate before calling

// Fastify: normalize the header on the error path before the built-in filter replies
import { catchError, throwError, CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';

@Injectable()
export class JsonErrorHeaderInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      catchError((err) => {
        const reply = ctx.switchToHttp().getResponse();
        const ct = reply.getHeader('Content-Type');
        if (ct !== undefined && ct !== 'application/json') {
          reply.header('Content-Type', 'application/json'); // exact match the adapter expects
        }
        return throwError(() => err);
      }),
    );
  }
}

Prevention

When it happens

Trigger: A handler or interceptor calls reply.header('Content-Type', 'text/html') (or any non-JSON value, including 'application/json; charset=utf-8') and the request then throws an HttpException (>= 400), so the built-in exception filter's JSON error body flows through reply() with the stale header present. Also custom exception filters or Fastify hooks (onSend, onResponse) that set a Content-Type before the error body is serialized.

Common situations: File-download/streaming routes that set Content-Type before an error occurs; SSR with @Render plus JSON error paths; Fastify hooks or plugins setting default Content-Type; content-type set to a charset-suffixed JSON value, which the exact-equality check still flags; warning surfacing after a Nest upgrade that introduced the check.

Related errors


AI-assisted analysis of nestjs/nest@3f8a0ce183 (2026-08-21). Data as JSON: /api/errors/628ccc4ec1206ef5. Report an issue: GitHub.