Skip to content

feat: support experimental.serverActions.bodySizeLimit from next.config#338

Open
youanden wants to merge 1 commit intocloudflare:mainfrom
youanden:feat/body-size-limit
Open

feat: support experimental.serverActions.bodySizeLimit from next.config#338
youanden wants to merge 1 commit intocloudflare:mainfrom
youanden:feat/body-size-limit

Conversation

@youanden
Copy link

@youanden youanden commented Mar 8, 2026

Adds support for reading experimental.serverActions.bodySizeLimit from next.config.ts and using it to configure the server action request body size limit. Currently, vinext hardcodes __MAX_ACTION_BODY_SIZE to 1MB (1 * 1024 * 1024), ignoring any user configuration and causing 413 Payload Too Large errors when uploading files >1MB via server actions.

// next.config.ts
export default {
  experimental: {
    serverActions: {
      bodySizeLimit: "10mb", // ❌ ignored by vinext — always 1MB
    },
  },
};

Next.js respects this setting (docs), but vinext's generated dev server entry hardcodes the limit.

Problem

I faced an issue uploading files above 1MB in dev mode that drove this PR's creation. I had created a patch in my repo beforehand to correct the problem locally:

// patches/vinext@0.0.24.patch
diff --git a/dist/config/next-config.js b/dist/config/next-config.js
index 3f049032fc986ad4561cb2674cac11bc244cc7b6..a781da763fa7ac2c10655df2a8a0b127bbc882b4 100644
--- a/dist/config/next-config.js
+++ b/dist/config/next-config.js
@@ -10,6 +10,27 @@ import { createRequire } from "node:module";
 import fs from "node:fs";
 import { PHASE_DEVELOPMENT_SERVER } from "../shims/constants.js";
 import { normalizePageExtensions } from "../routing/file-matcher.js";
+/**
+ * Parse a body size limit value (string or number) into bytes.
+ * Accepts Next.js-style strings like "1mb", "500kb", "10mb".
+ * Returns the default 1MB if the value is not provided or invalid.
+ */
+function parseBodySizeLimit(value) {
+    if (value === undefined || value === null) return 1 * 1024 * 1024;
+    if (typeof value === "number") return value;
+    if (typeof value !== "string") return 1 * 1024 * 1024;
+    const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/i);
+    if (!match) return 1 * 1024 * 1024;
+    const num = parseFloat(match[1]);
+    const unit = match[2].toLowerCase();
+    switch (unit) {
+        case "b": return Math.floor(num);
+        case "kb": return Math.floor(num * 1024);
+        case "mb": return Math.floor(num * 1024 * 1024);
+        case "gb": return Math.floor(num * 1024 * 1024 * 1024);
+        default: return 1 * 1024 * 1024;
+    }
+}
 const CONFIG_FILES = [
     "next.config.ts",
     "next.config.mjs",
@@ -105,6 +126,7 @@ export async function resolveNextConfig(config) {
             i18n: null,
             mdx: null,
             serverActionsAllowedOrigins: [],
+            serverActionsBodySizeLimit: 1 * 1024 * 1024,
         };
     }
     // Resolve redirects
@@ -135,12 +157,13 @@ export async function resolveNextConfig(config) {
     }
     // Extract MDX remark/rehype plugins from @next/mdx's webpack wrapper
     const mdx = extractMdxOptions(config);
-    // Resolve serverActions.allowedOrigins from experimental config
+    // Resolve serverActions.allowedOrigins and bodySizeLimit from experimental config
     const experimental = config.experimental;
     const serverActionsConfig = experimental?.serverActions;
     const serverActionsAllowedOrigins = Array.isArray(serverActionsConfig?.allowedOrigins)
         ? serverActionsConfig.allowedOrigins
         : [];
+    const serverActionsBodySizeLimit = parseBodySizeLimit(serverActionsConfig?.bodySizeLimit);
     // Warn about unsupported options (skip webpack if we extracted MDX from it)
     const unsupported = mdx ? [] : ["webpack"];
     for (const key of unsupported) {
@@ -177,6 +200,7 @@ export async function resolveNextConfig(config) {
         i18n,
         mdx,
         serverActionsAllowedOrigins,
+        serverActionsBodySizeLimit,
     };
 }
 /**
diff --git a/dist/index.js b/dist/index.js
index 6659b4a0edab80f91a21547ae08c6340ee101c7a..a30439bc9a17fbb3905eeb0f95ddabd12d4981c2 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -2139,6 +2139,7 @@ hydrate();
                         headers: nextConfig?.headers,
                         allowedOrigins: nextConfig?.serverActionsAllowedOrigins,
                         allowedDevOrigins: nextConfig?.serverActionsAllowedOrigins,
+                        bodySizeLimit: nextConfig?.serverActionsBodySizeLimit,
                     });
                 }
                 if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) {
diff --git a/dist/server/app-dev-server.js b/dist/server/app-dev-server.js
index e76418758ec636d05f0ea14ee7fffc367bbf937c..8ec09db8e5a8dc4e74cbe69904fb534923a06fe8 100644
--- a/dist/server/app-dev-server.js
+++ b/dist/server/app-dev-server.js
@@ -25,6 +25,7 @@ export function generateRscEntry(appDir, routes, middlewarePath, metadataRoutes,
     const rewrites = config?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] };
     const headers = config?.headers ?? [];
     const allowedOrigins = config?.allowedOrigins ?? [];
+    const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024;
     // Build import map for all page and layout files
     const imports = [];
     const importMap = new Map();
@@ -1112,12 +1113,13 @@ function __isExternalUrl(url) {
 }
 
 /**
- * Maximum server-action request body size (1 MB).
- * Matches the Next.js default for serverActions.bodySizeLimit.
+ * Maximum server-action request body size.
+ * Configurable via experimental.serverActions.bodySizeLimit in next.config.
+ * Defaults to 1MB, matching the Next.js default.
  * @see https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions#bodysizelimit
  * Prevents unbounded request body buffering.
  */
-var __MAX_ACTION_BODY_SIZE = 1 * 1024 * 1024;
+var __MAX_ACTION_BODY_SIZE = ${JSON.stringify(bodySizeLimit)};
 
 /**
  * Read a request body as text with a size limit.

Test plan

Added 14 unit tests covering string parsing (mb/kb/gb/b), numeric passthrough, case insensitivity, fractional values, and default/invalid fallbacks
Added integration tests verifying resolveNextConfig() end-to-end with experimental.serverActions.bodySizeLimit

All existing tests continue to pass, oxlint was clean, typecheck had 4 existing errors prior to my changes.

@james-elicx
Copy link
Collaborator

/bigbonk review

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 8, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@338

commit: fb5f4c5

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good feature addition — the 1MB hardcoded limit was a real pain point for file uploads. The overall architecture is correct: config parsing → resolved config → plumbed through generateRscEntry → embedded in generated code. The production server and Cloudflare worker entry inherit the limit from the built RSC entry, so they're covered automatically.

Tests pass and cover the main cases well. A few issues to address below, ranging from a behavioral mismatch with Next.js to minor suggestions.

if (value === undefined || value === null) return 1 * 1024 * 1024;
if (typeof value === "number") return value;
if (typeof value !== "string") return 1 * 1024 * 1024;
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/i);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavioral mismatch with Next.js: The bytes library that Next.js uses (via require('next/dist/compiled/bytes')) accepts formats that this regex rejects:

  1. Bare numbers as strings"1048576" (no unit) is treated as bytes by bytes.parse(), but this regex requires a unit suffix and would fall back to 1MB.
  2. Space between number and unit"10 mb" works in bytes.parse() and \s* in the regex handles this, so that's fine.
  3. Terabytes and petabytes — Next.js's FileSizeSuffix type includes tb, pb (and their uppercase variants). The bytes library also supports these. This implementation only handles b|kb|mb|gb.

The bare-number-as-string case is the most likely to bite users in practice. Consider handling it:

Suggested change
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/i);
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?$/i);
if (!match) return 1 * 1024 * 1024;
const num = parseFloat(match[1]);
const unit = (match[2] || 'b').toLowerCase();

This makes the unit optional (defaulting to bytes) and adds tb/pb support. You'd need to add case "tb": return Math.floor(num * 1024 * 1024 * 1024 * 1024); and case "pb" to the switch as well.

* Accepts Next.js-style strings like "1mb", "500kb", "10mb".
* Returns the default 1MB if the value is not provided or invalid.
*/
export function parseBodySizeLimit(value: string | number | undefined | null): number {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Next.js validates that the parsed value is >= 1 and throws an error for negative numbers or zero (see packages/next/src/server/config.ts). This implementation silently accepts 0, negative numbers, and "0mb" as valid. Consider adding validation:

if (value <= 0) return 1 * 1024 * 1024;

or even throwing, to match Next.js behavior:

if (value === null || isNaN(value) || value < 1) {
  throw new Error(
    'Server Actions Size Limit must be a valid number or filesize format larger than 1MB: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit'
  );
}

Not a blocker, but something to consider for parity.

export function parseBodySizeLimit(value: string | number | undefined | null): number {
if (value === undefined || value === null) return 1 * 1024 * 1024;
if (typeof value === "number") return value;
if (typeof value !== "string") return 1 * 1024 * 1024;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This branch is unreachable given the TypeScript signature string | number | undefined | null. At runtime with untyped JS it could theoretically be hit, but given that the caller casts to string | number | undefined, this is dead code. Not worth blocking on, just noting it.

it("returns default 1MB for invalid strings", () => {
expect(parseBodySizeLimit("invalid")).toBe(1 * 1024 * 1024);
expect(parseBodySizeLimit("")).toBe(1 * 1024 * 1024);
expect(parseBodySizeLimit("10tb")).toBe(1 * 1024 * 1024);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch testing "10tb" as invalid. However, per my comment on the parser — Next.js does support terabytes (tb/TB). If you update the parser to match, this assertion would need to change accordingly.

* Prevents unbounded request body buffering.
*/
var __MAX_ACTION_BODY_SIZE = 1 * 1024 * 1024;
var __MAX_ACTION_BODY_SIZE = ${JSON.stringify(bodySizeLimit)};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.stringify(bodySizeLimit) works correctly here since bodySizeLimit is always a number — it'll produce e.g. 10485760 in the generated code. Just noting for future readers that JSON.stringify on a number is equivalent to String() / template interpolation, so this is fine.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 8, 2026

Review submitted. Here's a summary of my feedback on PR #338:

Overall: The PR correctly implements the feature. The config parsing pipeline (next.config.tsresolveNextConfigindex.tsgenerateRscEntry → embedded __MAX_ACTION_BODY_SIZE) is properly wired end-to-end. Production builds and Cloudflare worker entries inherit the limit from the built RSC entry, so no changes needed there. The Pages Router API route hardcoded limits (api-handler.ts, index.ts:handleApiRoute) are a separate concern (bodyParser.sizeLimit) and are correctly left unchanged.

Key feedback:

  1. Behavioral mismatch with Next.js (most important): The parser regex requires a unit suffix (b|kb|mb|gb), but Next.js's bytes library accepts bare number strings like "1048576" (treated as bytes), and also supports tb/pb units. Suggested making the unit optional and adding tb/pb to the switch.

  2. Missing validation: Next.js validates that the parsed value is >= 1 and throws for negative/zero values. This implementation silently accepts them.

  3. Minor notes: Unreachable type guard on line 22, a test assertion for "10tb" that would change if tb support is added, and confirmation that JSON.stringify on the number output is fine.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants