withastro/astro · error · TypeError

Unsupported content type

Error message

Unsupported content type

What it means

parseRequestBody only understands the two content shapes actions support: form types (application/x-www-form-urlencoded, multipart/form-data) and application/json. If the request declares any other content-type (text/plain, application/xml, octet-stream...), parsing is skipped and this TypeError is thrown — Astro refuses to guess how to interpret the body.

Solutions

  1. Send the correct header: Content-Type: application/json for object payloads, or let the browser set form types when submitting FormData
  2. Remove explicit content-type overrides so fetch infers it from the body type
  3. Check intermediate proxies/middleware for header rewrites on _action requests

Example fix

// before
await fetch(actionPath, {
  method: 'POST',
  headers: { 'Content-Type': 'text/plain' },
  body: JSON.stringify(input),
});

// after
await fetch(actionPath, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(input),
});
Defensive patterns

Strategy: validation

Validate before calling

// set the header explicitly on every custom action fetch
await fetch(actionUrl, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(input),
});

Type guard

function isSupportedActionContentType(ct: string | null): boolean {
  if (!ct) return false;
  const base = ct.split(';')[0].trim().toLowerCase();
  return ['application/json', 'application/x-www-form-urlencoded', 'multipart/form-data'].includes(base);
}

Try / catch

try {
  await handleActionRequest(request);
} catch (e) {
  if (e instanceof TypeError && e.message === 'Unsupported content type') {
    return new Response('Unsupported media type', { status: 415 });
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling an action endpoint with fetch(..., { headers: { 'Content-Type': 'text/plain' } }); custom clients defaulting to other MIME types; middleware or proxies rewriting the content-type header; curl posts without -H 'Content-Type: application/json'.

Common situations: Hand-rolled action fetches copying generic fetch snippets; form-encoding libraries sending unusual MIME types; API gateways normalizing headers and breaking the content type.

Related errors


AI-assisted analysis of withastro/astro@52e6c34790 (2026-08-18). Data as JSON: /api/errors/384b8d65a602bc7a. Report an issue: GitHub.

Appendix: source

Thrown at packages/astro/src/actions/runtime/server.ts:303

		if (hasContentType(contentType, ['application/json'])) {
			if (contentLength === 0) return undefined;
			if (!hasContentLength) {
				const body = await readBodyWithLimit(request.clone(), bodySizeLimit);
				if (body.byteLength === 0) return undefined;
				return JSON.parse(new TextDecoder().decode(body));
			}
			return await request.clone().json();
		}
	} catch (e) {
		if (e instanceof BodySizeLimitError) {
			throw new ActionError({
				code: 'CONTENT_TOO_LARGE',
				message: `Request body exceeds ${bodySizeLimit} bytes`,
			});
		}
		throw e;
	}
	throw new TypeError('Unsupported content type');
}

export const ACTION_API_CONTEXT_SYMBOL = Symbol.for('astro.actionAPIContext');

const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];

function hasContentType(contentType: string, expected: string[]) {
	// Split off parameters like charset or boundary
	// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms
	const type = contentType.split(';')[0].toLowerCase();

	return expected.some((t) => type === t);
}

function isActionAPIContext(ctx: ActionAPIContext): boolean {
	const symbol = Reflect.get(ctx, ACTION_API_CONTEXT_SYMBOL);
	return symbol === true;
}

View on GitHub (pinned to 52e6c34790)