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
# .env.local β€” server-only, never prefixed with NEXT_PUBLIC_
FILES_LINK_API_KEY=your_api_key_here
FILES_LINK_FOLDER_ID=your_folder_id_here

The upload flow: three calls

Every method below is the same three steps. Understand these once and the rest is just where you run them.

  1. Create β€” POST /v1/files/{folderId} with filesMetadata: [{ name }]. You get back a short-lived presigned url, a file id, and a key.
  2. Upload β€” PUT the raw bytes straight to that presigned URL. No auth header β€” the signature is baked into the URL.
  3. Confirm β€” POST /v1/files/confirm-upload with { 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 Β· shared helper
// 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
// 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:

Client form β†’ /api/upload
// 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
// 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 Β· form β†’ Server 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).

Security note: never ship the raw files.link API key to the browser. Mint the presigned URL server-side (in a Route Handler) and hand the client only that short-lived URL. The key lives in two tiny server endpoints; the browser never sees it.

First, the two server endpoints β€” one to mint the URL, one to confirm:

app/api/presign/route.js
// 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
// 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
// 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_KEY and FILES_LINK_FOLDER_ID (no NEXT_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 fetch calls run from a pages/api/upload.js handler β€” 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

More guides on the Developer Guides hub.

files.link
Copyright Β© 2026
All rights reserved
ContactGuidesGlossaryStatusLegal