ScreenshotNeo

BlogHow-to

How to Upload Images With the Next.js App Router

Upload images in the Next.js App Router with a Server Action, a Route Handler, or direct-to-storage flow. Includes validation, durable storage, size limits, and fixes for common errors.

By the ScreenshotNeo team4 October 202611 min read

For a small image upload in the Next.js App Router, use a form whose action calls a Server Action. The action receives FormData; authenticate the user, authorize the destination, validate the file on the server, then write it to durable storage and save its object key or URL. Use a POST Route Handler when you need a conventional HTTP endpoint or custom response behavior. For larger files, consider a browser-to-storage upload so the image bytes do not pass through a Server Action request.

This guide uses the App Router. The storage and authentication calls below are clearly marked integration points: replace them with your provider and application code. Next.js and hosting limits vary, so verify the deployed request path before setting a production maximum.

1. Build the upload form

A Server Action is a convenient fit when a form submits data that updates the current application. Give the file input a stable name; that name is the key used to read it from FormData.

// app/upload/page.tsx
import { saveImage } from './actions'

export default function UploadPage() {
  return (
    <main>
      <h1>Upload an image</h1>
      <form action={saveImage}>
        <label htmlFor="image">Choose an image</label>
        <input
          id="image"
          name="image"
          type="file"
          accept="image/jpeg,image/png,image/webp"
          required
        />
        <button type="submit">Upload</button>
      </form>
    </main>
  )
}

The accept attribute guides the file picker. It does not validate the request or prove that the uploaded bytes are an image. Enforce all security and business rules on the server.

2. Validate and persist in a Server Action

Put the action in a module marked 'use server'. This example shows the request checks and the order of operations. Replace requireAuthenticatedUser and storeImageForUser with real application integrations; they are intentionally not presented as built-in Next.js APIs.

// app/upload/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

const MAX_IMAGE_BYTES = 800 * 1024
const ALLOWED_TYPES = new Set([
  'image/jpeg',
  'image/png',
  'image/webp',
])

type SaveResult = { ok: true } | { error: string }

export async function saveImage(formData: FormData): Promise<SaveResult> {
  // Application-specific: authenticate the caller on every invocation.
  const user = await requireAuthenticatedUser()

  const value = formData.get('image')
  if (!(value instanceof File) || value.size === 0) {
    return { error: 'Choose a non-empty image file.' }
  }

  if (value.size > MAX_IMAGE_BYTES) {
    return { error: 'The image must be 800 KB or smaller.' }
  }

  if (!ALLOWED_TYPES.has(value.type)) {
    return { error: 'Upload a JPEG, PNG, or WebP image.' }
  }

  // Application-specific: confirm this user may upload to this destination.
  // Production code should inspect file content where appropriate, generate a
  // safe storage key, and use durable object storage rather than a temp path.
  await storeImageForUser({
    userId: user.id,
    file: value,
  })

  revalidatePath('/images')
  return { ok: true }
}

// Replace these declarations with your auth and storage implementations.
declare function requireAuthenticatedUser(): Promise<{ id: string }>
declare function storeImageForUser(input: {
  userId: string
  file: File
}): Promise<{ key: string }>

The placeholder declarations make the integration boundary explicit; this file is not complete until you provide those implementations. In a real application, persist the returned storage key and ownership metadata in your database. If the UI needs to show validation results inline, connect the action result to the form with the action-state pattern documented by Next.js.

Validation decisions

  • Presence: confirm the submitted field is a File and is not empty.
  • Size: set a product-appropriate per-file limit and enforce it server-side. Also account for the entire request body limit.
  • Format: allow only formats the application can safely serve and process. Browser-provided MIME type is a signal, not proof of the file contents.
  • Content inspection: for higher-risk applications, inspect file signatures, decode and re-encode images, or scan uploads. Choose these controls for your threat model.
  • Identity and destination: authenticate the caller and authorize access to the particular user, record, or collection receiving the file.
  • Storage name: generate an opaque storage key instead of trusting the submitted filename. Keep the original name only as metadata if the product needs it.

3. Use a Route Handler for an explicit HTTP endpoint

A Route Handler is useful when another client needs to call a stable URL, when you need explicit status codes or response JSON, or when upload behavior should be exposed as an HTTP API. Multipart form parsing uses await request.formData().

// app/api/upload/route.ts
const MAX_IMAGE_BYTES = 800 * 1024
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])

export async function POST(request: Request) {
  // Application-specific: do not rely on the page hiding this endpoint.
  const user = await requireAuthenticatedUser(request)
  if (!user) {
    return Response.json({ error: 'Sign in to upload.' }, { status: 401 })
  }

  const formData = await request.formData()
  const value = formData.get('image')

  if (!(value instanceof File) || value.size === 0) {
    return Response.json({ error: 'A non-empty image is required.' }, { status: 400 })
  }
  if (value.size > MAX_IMAGE_BYTES) {
    return Response.json({ error: 'Image exceeds the 800 KB limit.' }, { status: 413 })
  }
  if (!ALLOWED_TYPES.has(value.type)) {
    return Response.json({ error: 'Unsupported image type.' }, { status: 415 })
  }

  // Application-specific authorization and durable storage integration.
  await storeImageForUser({ userId: user.id, file: value })
  return Response.json({ ok: true }, { status: 201 })
}

declare function requireAuthenticatedUser(request: Request): Promise<{ id: string } | null>
declare function storeImageForUser(input: { userId: string; file: File }): Promise<unknown>

The handler is a public endpoint. Run authentication, authorization, and validation inside it on every request. Return only information the caller needs; do not expose credentials, internal exceptions, or stack traces.

Call the Route Handler from a browser form

Native form submission works when the endpoint returns a page-friendly response. For a client-side interaction that needs JSON, construct FormData and let the browser set the multipart content type and boundary:

'use client'

export function UploadForm() {
  async function submit(formData: FormData) {
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData,
    })
    const result = await response.json()

    if (!response.ok) {
      throw new Error(result.error ?? 'Upload failed')
    }
    return result
  }

  return (
    <form action={submit}>
      <input name="image" type="file" accept="image/*" required />
      <button type="submit">Upload</button>
    </form>
  )
}

This client component example uses a form action function for the browser-side fetch; you can also wire the request to a button handler and display pending, success, and error states. Do not manually set Content-Type: multipart/form-data when sending a FormData body with fetch; the browser must add the boundary parameter.

4. Choose the upload path for the file size and API shape

Approach Best fit What to check
Server Action Small form upload tied to a page mutation Action request-body cap, hosting limits, authentication and authorization in the action
Route Handler Explicit HTTP endpoint, custom status or JSON response Same request-size constraints may apply; authenticate and authorize in the handler
Direct to object storage Larger files or avoiding the app server as the byte-transfer path Protect token issuance, constrain upload scope, validate the completed object, save ownership and object metadata

Successful multipart parsing does not mean the image is durably stored. Some serverless environments do not support persistent filesystem writes. Prefer durable object storage for uploaded images, then store the object reference and related metadata in your database.

5. Understand Server Action request-body limits

The current Next.js configuration documentation lists a default Server Action request-body limit of 1 MB. The multipart body also contains boundaries, part headers, and fields, so the raw request is larger than the file itself. Next.js gives 10–20 KB as a reasonable overhead allowance when choosing a cap.

You can set a different cap in next.config.js:

// next.config.js
module.exports = {
  experimental: {
    serverActions: {
      bodySizeLimit: '2mb',
    },
  },
}

The setting changes the Next.js Server Action cap; it does not raise limits enforced by your host, proxy, runtime, or storage service. Keep the application’s per-image validation limit below the effective end-to-end request ceiling. Raising a body cap also means the server may have to receive and process larger untrusted payloads, so set it only as high as the feature requires.

6. Upload larger images directly to storage

When the application server should not receive the image bytes, use a storage provider’s browser upload flow. For Vercel Blob, the documented flow exchanges a token with a server route, then transfers the file from the browser to Blob. Vercel recommends client uploads when files are larger than 4.5 MB; that figure applies to its documented Blob approach, not as a universal Next.js limit.

The security boundary moves to token issuance: authenticate and authorize the user in the token callback, constrain accepted content types and upload permissions, and associate the resulting object with the intended user or record. Validate the completed upload and persist its object reference. Consult the provider’s current documentation for its route and SDK implementation details.

7. Support multiple images

For a small, bounded set of files, add the multiple attribute and read every entry with getAll. Set limits on both the number of files and their combined byte size.

const entries = formData.getAll('images')
const files = entries.filter((entry): entry is File => entry instanceof File)

if (files.length === 0 || files.length !== entries.length) {
  return { error: 'Choose one or more valid image files.' }
}
if (files.length > 5) {
  return { error: 'Upload no more than five images at once.' }
}
if (files.some((file) => file.size === 0 || file.size > MAX_IMAGE_BYTES)) {
  return { error: 'Each image must be non-empty and within the size limit.' }
}

// Validate each format and authorize the destination, then store each file.
// Also enforce an aggregate byte limit across the whole submission.

Corresponding field:

<input name="images" type="file" accept="image/jpeg,image/png,image/webp" multiple required />

Decide what should happen if one file fails: reject the whole batch for atomic behavior, or return per-file results for partial success. Avoid accepting unbounded batches; request size and processing time grow with every file.

8. Troubleshooting

Symptom Likely cause Fix
Request is rejected before the action runs Server Action body exceeds the configured cap, or a host/proxy limit is lower Reduce the image size, set a suitable bodySizeLimit, and verify every deployed layer’s cap. Use direct-to-storage for larger files.
File field is null The input name does not match the key read by formData.get, or the request omitted the field Use matching names, such as name="image" and get('image'); inspect getAll for repeated fields.
File is present but rejected as the wrong type Browser MIME metadata differs from the allowlist, or the client submitted a different format Choose the formats the product supports, show clear validation feedback, and inspect file content where the risk warrants it.
Upload succeeds but the image disappears after deployment or another request Bytes were written to an ephemeral or unsupported local filesystem Use durable object storage and persist its object key or URL in the application database.
Route Handler returns HTML or an unexpected status Request was sent to the wrong route, method, or response path Confirm the file is at app/api/upload/route.ts, send POST, and inspect the actual status and response body.
Multipart parser reports a malformed request Client manually set a multipart content type without a matching boundary When using browser fetch with FormData, omit the content-type header.
Direct upload token is denied or allows too much Token callback authorization or upload constraints are missing or incorrect Authenticate and authorize before issuing the token; restrict scope, file types, and destination according to the provider’s capabilities.

9. Performance, reliability, and cost

  • Request path: a Server Action or Route Handler sends bytes through the application request path. Direct-to-storage avoids routing the file payload through that path, which can help for larger files.
  • Memory and duration: parsing and processing large files consumes server resources and can approach runtime duration limits. Keep files and batches bounded, and move expensive transformations to a suitable worker or storage pipeline.
  • Retries: uploads can be retried after network failures. Use unique object keys or an idempotency strategy so a retry does not unexpectedly create duplicate records. Clean up orphaned objects if database persistence fails after storage succeeds.
  • Durability: use persistent object storage rather than assuming local disk survives serverless invocations or deployments. Store ownership, content type, byte size, and object reference alongside the application record.
  • Cost: budget for storage, transfer, image transformations, and any scanning or processing your design adds. The exact cost depends on provider, region, retention, traffic, and file sizes; check current provider pricing rather than assuming the upload endpoint is the only cost.
  • Abuse controls: consider per-user quotas, rate limits, aggregate batch limits, and cleanup policies. Validate before expensive processing and avoid logging file contents or secrets.

10. Or skip the browser setup

If the task is capturing a webpage as an image rather than uploading an image selected by a visitor, ScreenshotNeo provides a website screenshot API and MCP server. Its one-call API can return PNG, JPEG, WebP, or PDF; it does not replace an upload form for user-selected files.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());

See the ScreenshotNeo API documentation for request options and response details. Consent banners, newsletter popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000.

Sign up free for ScreenshotNeo and get 1,000 screenshots a month with no card.

11. Frequently asked questions

Can I upload from a Server Component?

The form can be rendered by a Server Component, but the submitted mutation should be a Server Function or an HTTP endpoint. The upload still needs server-side checks and durable storage.

Does the App Router save uploaded files automatically?

No. It gives you request-handling patterns such as Server Actions and Route Handlers. Your application must validate and persist the file.

Should I store image bytes in my database?

For most web applications, store the file in object storage and keep its key or URL plus ownership and other metadata in the database. Choose a different design only when your database and access patterns make that appropriate.

Is a Route Handler automatically better for big files?

No. It gives you an explicit HTTP API, but the request still passes through your app and deployment limits still apply. A direct-to-storage flow changes the byte-transfer path.

References