Next.js file upload tutorial (App Router)
Next.js has no built-in durable file store. The moment you fs.writeFile something to disk in a Route Handler, you've written it to an ephemeral filesystem β on Vercel, a container, or any serverless/edge host, that file is gone on the next cold start or deploy. What you actually want is a permanent CDN URL you can store in your database and render with next/image.
This tutorial shows three complete, copy-paste ways to upload a file from a Next.js App Router app to a permanent URL: a Route Handler, a Server Action, and a direct browser upload. No SDK β just the built-in fetch. The same code runs on Vercel, Netlify, Railway, a self-hosted Node container, or bare metal, with no platform coupling. If you just want the raw HTTP calls in any language, the sibling upload-a-file guide covers curl, browser, and Node. For a no-code, one-off share, the file-to-link generator turns a file into a URL straight from the dashboard.
Before you start
You need an API key and a folder id from your dashboard. Put both in .env.local. The single most important rule in this whole tutorial: never prefix the API key with NEXT_PUBLIC_ and never read it from a "use client" component β either one inlines the secret into the browser bundle.
# .env.local β server-only, never prefixed with NEXT_PUBLIC_
FILES_LINK_API_KEY=your_api_key_here
FILES_LINK_FOLDER_ID=your_folder_id_hereThe upload flow: three calls
Every method below is the same three steps. Understand these once and the rest is just where you run them.
- Create β
POST /v1/files/{folderId}withfilesMetadata: [{ name }]. You get back a short-lived presignedurl, a fileid, and akey. - Upload β
PUTthe raw bytes straight to that presigned URL. No auth header β the signature is baked into the URL. - Confirm β
POST /v1/files/confirm-uploadwith{ ids: [id] }. Uploads you never confirm are garbage-collected.
The public URL is just the CDN base plus the key from step 1: https://cdn.files.link/<key>. Let's put those three calls in one reusable helper so every method can share it.
// lib/fileslink.js β the 3-call upload, shared by every method below.
// Runs on the server only; the API key never leaves this process.
const API = "https://api.files.link/v1";
const headers = () => ({
Authorization: process.env.FILES_LINK_API_KEY, // raw key, NO "Bearer" prefix
"Content-Type": "application/json",
});
// Upload bytes you already have on the server (Buffer / Blob / Uint8Array).
// Returns the public CDN URL.
export async function uploadBytes({ name, type, size, bytes }) {
const folderId = process.env.FILES_LINK_FOLDER_ID;
// 1. Mint a presigned upload URL.
const createRes = await fetch(`${API}/files/${folderId}`, {
method: "POST",
headers: headers(),
body: JSON.stringify({ filesMetadata: [{ name, size, type }] }),
});
if (!createRes.ok) throw new Error(`create failed: ${createRes.status}`);
const { urls } = await createRes.json();
const { url, id, key } = urls[0];
// 2. PUT the raw bytes straight to storage (no auth header β it's in the URL).
const putRes = await fetch(url, {
method: "PUT",
headers: { "Content-Type": type || "application/octet-stream" },
body: bytes,
});
if (!putRes.ok) throw new Error(`put failed: ${putRes.status}`);
// 3. Confirm so the file is finalized (unconfirmed uploads are cleaned up).
const confirmRes = await fetch(`${API}/files/confirm-upload`, {
method: "POST",
headers: headers(),
body: JSON.stringify({ ids: [id] }),
});
if (!confirmRes.ok) throw new Error(`confirm failed: ${confirmRes.status}`);
return { id, key, cdnUrl: `https://cdn.files.link/${key}` };
}Method A β Route Handler (app/api/upload/route.js)
A Route Handler is the right choice when you want a stable POST endpoint that a client fetch (or an external caller) can hit. The browser sends the file as FormData; the handler reads it, runs the three calls with the key kept server-side, and returns the CDN URL.
// app/api/upload/route.js β POST a file, get a CDN URL back.
import { uploadBytes } from "@/lib/fileslink";
export const runtime = "nodejs"; // Node runtime: no body-size/CPU edge limits
export async function POST(req) {
const form = await req.formData();
const file = form.get("file");
if (!file || typeof file === "string") {
return Response.json({ error: "No file provided" }, { status: 400 });
}
// A File from FormData β bytes the server can PUT.
const bytes = Buffer.from(await file.arrayBuffer());
const { cdnUrl } = await uploadBytes({
name: file.name,
type: file.type,
size: bytes.length,
bytes,
});
return Response.json({ url: cdnUrl });
}And a small client component to drive it:
// A client form that posts to the route handler above.
"use client";
import { useState } from "react";
export default function UploadForm() {
const [url, setUrl] = useState("");
const [busy, setBusy] = useState(false);
async function onSubmit(e) {
e.preventDefault();
setBusy(true);
const body = new FormData();
body.append("file", e.target.file.files[0]);
const res = await fetch("/api/upload", { method: "POST", body });
const data = await res.json();
setUrl(data.url);
setBusy(false);
}
return (
<form onSubmit={onSubmit}>
<input type="file" name="file" required />
<button type="submit" disabled={busy}>
{busy ? "Uploadingβ¦" : "Upload"}
</button>
{url && <a href={url}>{url}</a>}
</form>
);
}Note export const runtime = "nodejs". The Node runtime avoids the Edge runtime's tight body-size and CPU limits, which matters when the file bytes pass through your function. (If you don't want bytes flowing through your server at all, jump to Method C.)
Method B β Server Action ("use server")
Server Actions are the most idiomatic App Router pattern when the upload is driven by a form inside your React tree. The function is marked "use server", runs only on the server, and can be wired directly to a form's action prop β no onSubmit, no manual fetch.
// app/actions/upload.js β a Server Action.
"use server";
import { uploadBytes } from "@/lib/fileslink";
export async function uploadAction(formData) {
const file = formData.get("file");
if (!file || typeof file === "string") {
throw new Error("No file provided");
}
const bytes = Buffer.from(await file.arrayBuffer());
const { cdnUrl } = await uploadBytes({
name: file.name,
type: file.type,
size: bytes.length,
bytes,
});
// Return the URL (or revalidatePath('/') to refresh a server-rendered list).
return cdnUrl;
}The form is then dead simple β pass the action straight to the <form action={...}>:
// app/page.js β a minimal form wired straight to the Server Action.
// No onSubmit, no fetch: the form's action IS the server function.
import { uploadAction } from "./actions/upload";
export default function Page() {
async function handle(formData) {
"use server";
const url = await uploadAction(formData);
console.log("Uploaded to:", url);
}
return (
<form action={handle}>
<input type="file" name="file" required />
<button type="submit">Upload</button>
</form>
);
}After confirm-upload returns, call revalidatePath or revalidateTag to re-render any server component that lists the uploaded files β the new CDN URL shows up on the next request without a full reload.
Method C β Direct browser upload (no bytes through your server)
Methods A and B proxy the file bytes through your server. For large files β or to dodge serverless body-size limits entirely β let the browser PUT the bytes straight to storage. Your server only ever handles the two small JSON calls (create + confirm).
First, the two server endpoints β one to mint the URL, one to confirm:
// app/api/presign/route.js β mint a presigned URL WITHOUT touching the bytes.
// The browser will PUT directly to storage, so the API key stays on the server.
export const runtime = "nodejs";
const API = "https://api.files.link/v1";
export async function POST(req) {
const { name, type, size } = await req.json();
const res = await fetch(`${API}/files/${process.env.FILES_LINK_FOLDER_ID}`, {
method: "POST",
headers: {
Authorization: process.env.FILES_LINK_API_KEY, // never sent to the client
"Content-Type": "application/json",
},
body: JSON.stringify({ filesMetadata: [{ name, size, type }] }),
});
const { urls } = await res.json();
const { url, id, key } = urls[0];
// Hand the browser only the short-lived URL + ids β never the API key.
return Response.json({ url, id, key });
}// app/api/confirm/route.js β finalize after the browser PUTs the bytes.
export const runtime = "nodejs";
const API = "https://api.files.link/v1";
export async function POST(req) {
const { id } = await req.json();
await fetch(`${API}/files/confirm-upload`, {
method: "POST",
headers: {
Authorization: process.env.FILES_LINK_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ ids: [id] }),
});
return Response.json({ ok: true });
}Then the client component. It asks your server for a URL, PUTs the File straight to storage, and asks your server to confirm:
// app/components/DirectUpload.jsx β client component.
// Bytes go browser β storage directly; they never pass through your server.
"use client";
import { useState } from "react";
export default function DirectUpload() {
const [cdnUrl, setCdnUrl] = useState("");
async function onChange(e) {
const file = e.target.files[0];
if (!file) return;
// 1. Ask YOUR server for a presigned URL (API key stays server-side).
const presignRes = await fetch("/api/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: file.name,
type: file.type,
size: file.size,
}),
});
const { url, id, key } = await presignRes.json();
// 2. PUT the File straight to storage from the browser.
await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file,
});
// 3. Tell YOUR server to confirm (key stays server-side here too).
await fetch("/api/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
setCdnUrl(`https://cdn.files.link/${key}`);
}
return (
<div>
<input type="file" onChange={onChange} />
{cdnUrl && <a href={cdnUrl}>{cdnUrl}</a>}
</div>
);
}Displaying the uploaded file
Public files live at the CDN base plus the key returned in step 1 β https://cdn.files.link/<key> β and that URL drops straight into next/image as the src (add the host to images.remotePatterns in next.config.js). Files are served from a global CDN with edge locations, so the URL is fast everywhere.
Private files (user uploads, gated documents) aren't publicly reachable. Store them as private, then have your server mint a signed URL after an auth check when you need to serve one β it expires after 10 minutes, so generate it on demand rather than caching it.
Notes & gotchas
- Keep the key server-side. The key only ever appears in Route Handlers and Server Actions. Methods A, B, and C all hold this line β Method C does it by minting the URL on the server and PUTting from the client.
- Environment variables. Use
FILES_LINK_API_KEYandFILES_LINK_FOLDER_ID(noNEXT_PUBLIC_prefix). Set them in your host's dashboard for production. - Large files. Prefer Method C so multi-hundred-MB uploads never hit your serverless function's body limit. Bytes take the shortest path: device β storage.
- Confirm, or it disappears. A file created and PUT but never confirmed is treated as abandoned and garbage-collected. Always run step 3.
- Pages Router? The exact same three
fetchcalls run from apages/api/upload.jshandler β only how you read the request body differs.
Ship your first upload
files.link is prepaid and pay-as-you-go: create an account, add a payment method, grab an API key and folder id, and paste in one of the methods above. You pay only for what you store and serve.
Related
- Next.js file upload β the product page: Route Handlers vs Server Actions, Vercel Blob comparison.
- Next.js file storage β durable storage for Next.js without the ephemeral-filesystem trap.
- Express file upload β the same flow from a Node/Express backend.
- React file storage β client-side patterns for any React app.
- How to upload a file via API β the language-agnostic curl / browser / Node walkthrough.
More guides on the Developer Guides hub.