withastro/astro · error · ActionError

BAD_REQUEST

BAD_REQUEST

Error message

Failed to serialize request body to JSON. Full error: ${(e as Error).message}

What it means

Before the browser sends an action call that is not FormData, the astro:actions client serializes the argument with JSON.stringify. If the value cannot be serialized — circular references, BigInt, symbols as keys — stringify throws, and the client wraps it as an ActionError with code BAD_REQUEST instead of posting an unusable body.

Solutions

  1. Convert BigInt to string before the call: String(value) or value.toString()
  2. Break circular references — send a plain DTO, not the live object graph
  3. For files or rich data, submit FormData instead (define the action with accept: 'form' if needed)

Example fix

// before
const id = 12345678901234567890n; // BigInt
await actions.saveProfile({ id }); // JSON.stringify throws

// after
await actions.saveProfile({ id: id.toString() });
Defensive patterns

Strategy: validation

Validate before calling

// cheap pre-flight serialization check before calling the action
function assertJsonSerializable(value: unknown): void {
  JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? throwSerializationError('BigInt') : v));
}
function throwSerializationError(t: string): never { throw new Error(`Not JSON serializable: ${t}`); }
assertJsonSerializable(payload); // throws locally with a clear message
await actions.save(payload);

Type guard

function isPlainJsonSerializable(v: unknown): boolean {
  if (v === null || ['string', 'number', 'boolean'].includes(typeof v)) return true;
  if (typeof v === 'bigint' || typeof v === 'function' || typeof v === 'symbol') return false;
  if (Array.isArray(v)) return v.every(isPlainJsonSerializable);
  if (v instanceof Object) {
    const seen = new WeakSet();
    // shallow circularity check plus recursion
    return Object.values(v).every((x) => isPlainJsonSerializable(x));
  }
  return false;
}

Try / catch

try {
  await actions.save(payload);
} catch (e) {
  if (e instanceof ActionError && e.code === 'BAD_REQUEST' && e.message.includes('serialize')) {
    // sanitize payload: stringify BigInts, strip circular refs, then retry once
  } else throw e;
}

Prevention

When it happens

Trigger: Calling an action with a circular object (a DOM node graph, an object referencing itself), a BigInt, or class instances whose toJSON fails. Example: actions.save(bigintValue) or passing a parsed graph that contains parent back-references.

Common situations: Logging entities with circular references (user.friend = user); sending IDs as BigInt from a database layer; passing class instances (Map/Set are silently lost, BigInt/circular throw) from client components.

Understand the failure class

Background: BAD_REQUEST error code: request rejected as invalid (HTTP 400) - causes and fixes across libraries — this error's family across 8 libraries.

Related errors


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

Appendix: source

Thrown at packages/astro/src/actions/runtime/entrypoints/client.ts:53

export const getActionPath = createGetActionPath({
	baseUrl: import.meta.env.BASE_URL,
	shouldAppendTrailingSlash,
});

export const actions = createActionsProxy({
	handleAction: async (param, path) => {
		const headers = new Headers();
		headers.set('Accept', 'application/json');
		// Apply adapter-specific headers for internal fetches
		for (const [key, value] of Object.entries(internalFetchHeaders)) {
			headers.set(key, value);
		}
		let body = param;
		if (!(body instanceof FormData)) {
			try {
				body = JSON.stringify(param);
			} catch (e) {
				throw new ActionError({
					code: 'BAD_REQUEST',
					message: `Failed to serialize request body to JSON. Full error: ${(e as Error).message}`,
				});
			}
			if (body) {
				headers.set('Content-Type', 'application/json');
			} else {
				headers.set('Content-Length', '0');
			}
		}
		const rawResult = await fetch(
			getActionPathFromString({
				baseUrl: import.meta.env.BASE_URL,
				shouldAppendTrailingSlash,
				path: getActionQueryString(path),
			}),
			{
				method: 'POST',

View on GitHub (pinned to 52e6c34790)