Engineering

Stop returning HTML in your API responses (and what to do instead)

March 4, 20265 min read
Stop returning HTML in your API responses

You submit a form. The network tab shows a 400 status. You look at the response body and see this:

<p class="error">Email address is required.</p>

Now your React component gets this string and tries to render it. In the best case, you display it in a <div> and the user sees the HTML tags literally: <p class="error">Email address is required.</p>. In the worst case, you use dangerouslySetInnerHTML to make it look right and introduce an XSS vector in the process.

Neither outcome is what you wanted. The problem isn't the frontend code. It's the API.

Why APIs shouldn't return HTML

The job of an API is to return data. The job of a frontend is to turn that data into UI. When an API returns HTML, it's crossing a line: it's making UI decisions that belong to the frontend.

There are two concrete problems with this:

Tight coupling: your backend now controls how errors look in the UI. If you want to display errors differently on mobile vs. desktop, or change the error text, or translate it, you have to change the backend. The presentation layer and the data layer have merged in the wrong direction.

Frontend incompatibility: React, Vue, Svelte, and every other frontend framework expect data. They render data into UI. If you feed them HTML strings, you're working against the grain of every modern framework.

A frontend that consumes an API should be able to render that data however it wants. An API that returns HTML has already made those decisions for you.

The correct pattern: structured JSON errors

The right shape for an error response is JSON with a predictable structure. Here's a format that works for most form validation scenarios:

{
  "success": false,
  "errors": {
    "email": "Email address is required.",
    "message": "Message must be at least 10 characters."
  }
}

This format lets the frontend:

  • Know whether the request succeeded (success: false)
  • Show errors next to the right field (errors.email, errors.message)
  • Format, translate, or transform the text however it needs to

The backend implementation is straightforward:

// app/api/contact/route.ts
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const data = await req.formData();

  const errors: Record<string, string> = {};

  const email = String(data.get("email") ?? "").trim();
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.email = "A valid email address is required.";
  }

  const message = String(data.get("message") ?? "").trim();
  if (!message || message.length < 10) {
    errors.message = "Message must be at least 10 characters.";
  }

  if (Object.keys(errors).length > 0) {
    return NextResponse.json({ success: false, errors }, { status: 400 });
  }

  // Handle successful submission...
  return NextResponse.json({ success: true });
}

Consuming the errors in React

With structured JSON errors, the frontend can map them to the right place:

// components/ContactForm.tsx
"use client";

import { useState } from "react";

type FormErrors = Record<string, string>;

export function ContactForm() {
  const [errors, setErrors] = useState<FormErrors>({});
  const [submitting, setSubmitting] = useState(false);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setSubmitting(true);
    setErrors({});

    const formData = new FormData(e.currentTarget);

    try {
      const res = await fetch("/api/contact", {
        method: "POST",
        body: formData,
      });

      const json = await res.json();

      if (!json.success) {
        setErrors(json.errors ?? {});
        return;
      }

      // Handle success
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input name="email" type="email" aria-describedby="email-error" />
        {errors.email && (
          <p id="email-error" role="alert">
            {errors.email}
          </p>
        )}
      </div>
      <div>
        <textarea name="message" aria-describedby="message-error" />
        {errors.message && (
          <p id="message-error" role="alert">
            {errors.message}
          </p>
        )}
      </div>
      <button type="submit" disabled={submitting}>
        {submitting ? "Sending..." : "Send"}
      </button>
    </form>
  );
}

The error for each field lives next to that field. It's accessible (role="alert", aria-describedby). It can be styled however the design requires. The backend had no say in any of that.

Edge cases worth handling

Multiple errors per field: if you want to return several issues for one field (e.g., "required" and "too short"), use an array:

{
  "errors": {
    "password": ["Must be at least 8 characters.", "Must include a number."]
  }
}

Decide upfront whether your errors values are strings or arrays of strings and keep it consistent across your API. Inconsistency is what breaks frontend code.

A top-level error: sometimes the failure isn't field-specific. Rate limiting, server errors, or business logic failures don't map to a single input. Use a separate key:

{
  "success": false,
  "error": "Too many submissions. Please wait a few minutes and try again."
}

Reserve errors (plural) for field-level issues and error (singular) for request-level issues. Your frontend can check which one is present and render accordingly.

Localization: if you need to support multiple languages, return error codes instead of (or alongside) human-readable messages:

{
  "errors": {
    "email": {
      "code": "REQUIRED",
      "message": "Email address is required."
    }
  }
}

The frontend can use the code to look up the appropriate translation. The message is a fallback for clients that don't implement localization.

The principle underneath

There's a simple test for whether an API is doing its job correctly: could a completely different frontend, written by a different team, consume this API without any coordination?

An API that returns HTML says no. It has built-in assumptions about the UI. A different frontend can't use it without parsing and stripping HTML, or just ignoring the error structure entirely.

An API that returns structured JSON says yes. Any client that can parse JSON and read object keys can display these errors correctly, in whatever form its design requires.

APIs return data. Frontends turn data into UI. Keeping that boundary clean is what makes both sides easier to build and maintain.

For more on what production-ready form error handling looks like end to end, see AJAX form validation: common mistakes and how to fix them. For securing your endpoint beyond error handling, see best ways to secure a form endpoint.

Want clean JSON errors without writing the validation layer?

Formtorch handles form validation and returns structured error responses out of the box.

Related posts