File Uploads in Next.js: Server Actions and Route Handlers

How to accept file uploads in Next.js: read a File from FormData in a Server Action or a Route Handler, and choose the right one for the job.

6 min read

File uploads in Next.js read a File object out of FormData. A file input puts the file into the form, and you read it in either a Server Action or a Route Handler before writing its bytes to storage.

The Server Action is the form-first option. Read the file, confirm it is not a string, and convert it to bytes with arrayBuffer before handing it to your storage layer.

typescripttypescript
// app/actions.ts
'use server'
import { saveToStorage } from '@/lib/storage'
 
export async function upload(formData: FormData) {
  const file = formData.get('file')
  if (typeof file === 'string' || !file) return
  await saveToStorage(file.name, await file.arrayBuffer())
}

The guard matters because formData.get returns a string for a text field and a File for a file input. The File object exposes name, size, and type alongside the bytes, and arrayBuffer reads those bytes into memory.

Check the type and size yourself before storing anything. A file input's accept attribute filters the picker but does not stop a direct POST, so treat the reported name and type as untrusted and confirm the visitor is allowed to upload at all.

App.tsxApp.tsx
// app/page.tsx
import { upload } from './actions'
export default function UploadForm() {
  return <form action={upload}>
    <label htmlFor="file">Photo</label>
    <input id="file" name="file" type="file" accept="image/*" required />
    <button type="submit">Upload</button>
  </form>
}

The file input posts its File with the rest of the form, and the action reads it on the server. The accept and required attributes filter the picker and block an empty submit in the browser.

Where you store the bytes is up to you. Object storage, the server's filesystem, or a database all work, because the action runs on the server where those services are reachable.

The 1MB Server Action limit

Server Actions cap request bodies at 1MB by default. A larger file is rejected before the action runs, so either raise the limit or route the upload through a Route Handler instead.

typescripttypescript
// next.config.ts
import type { NextConfig } from 'next'
 
const nextConfig: NextConfig = {
  experimental: { serverActions: { bodySizeLimit: '2mb' } },
}
 
export default nextConfig

The option sits under experimental, and it accepts a byte count or a string like '500kb'. The limit applies to the raw request body, so multipart boundaries and field metadata count toward it. Leave roughly 10 to 20 KB of headroom above your largest real file.

The default is low on purpose, because the action endpoint accepts POSTs from anyone. Raise it only as far as your files need, and keep a size check in the action anyway.

Uploading through a Route Handler

A Route Handler reads the same FormData from the incoming request with request.formData. It is the right choice for large files, public endpoints, or uploads sent from a plain fetch call.

typescripttypescript
// app/api/upload/route.ts
import { saveToStorage } from '@/lib/storage'
 
export async function POST(request: Request) {
  const file = (await request.formData()).get('file')
  if (typeof file === 'string' || !file) {
    return Response.json({ error: 'No file.' }, { status: 400 })
  }
  await saveToStorage(file.name, await file.arrayBuffer())
  return Response.json({ name: file.name, size: file.size })
}

The handler returns JSON instead of UI, which suits an API surface. A missing file gets a 400 with a message, since there is no form to render an error back into. Route Handlers do not share the Server Action body limit, so they are the natural home for large uploads.

That is a Next.js limit, not the only one. Your host sets its own request body ceiling and function timeout, so check those before promising a large upload works in production.

Whichever path you choose, store the bytes you read and return only the metadata the client needs, never the raw file back into the response.

A client can also post a file to the handler with fetch and a FormData body, which is how non-form uploads and drag-and-drop interfaces send files. Do not set a Content-Type header yourself in that case, because the browser has to add the multipart boundary for you.

The trade-off between the two is mostly about where the response goes. A Server Action can revalidate the page and show the new file in the same roundtrip, while a Route Handler hands back JSON that your own code has to do something with.

NeedUse
Small file inside a formServer Action
Large file or public APIRoute Handler

See route handlers in Next.js for the route.js convention, and reading FormData in a Server Action for the field methods.

Rune AI

Rune AI

Key Insights

  • A file input puts a File into the form's FormData.
  • Read it with formData.get and await file.arrayBuffer().
  • Server Actions cap request bodies at 1MB by default.
  • Route Handlers read the same data with request.formData().
  • Pick the handler based on file size and API needs.
RunePowered by Rune AI

Frequently Asked Questions

What does formData.get return for a file input?

A File object with name, size, and type, or null when no file was chosen. Read its bytes with await file.arrayBuffer().

Why does my Server Action reject a large file?

Server Action requests are capped at 1MB by default. Raise experimental.serverActions.bodySizeLimit in next.config, or send the upload through a Route Handler.

Should I use a Server Action or a Route Handler?

Use a Server Action for small files inside a form. Use a Route Handler for large files, public API endpoints, or uploads from a plain fetch call.

Conclusion

File uploads read a File object out of FormData. A Server Action is the form-first option for small files, while a Route Handler handles large files and public endpoints without the 1MB action limit.