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.
// 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/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.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: { serverActions: { bodySizeLimit: '2mb' } },
}
export default nextConfigThe 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.
// 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.
| Need | Use |
|---|---|
| Small file inside a form | Server Action |
| Large file or public API | Route 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
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.
Frequently Asked Questions
What does formData.get return for a file input?
Why does my Server Action reject a large file?
Should I use a Server Action or a Route Handler?
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.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.