The full Server Action + form
App Router pattern. Server Action keeps the API key off the client. The form posts to it; the redirect or revalidate happens server-side.
// app/upload/actions.ts
'use server';
export async function uploadAction(formData: FormData) {
const file = formData.get('file') as File | null;
if (!file) return { error: 'No file' };
const fd = new FormData();
fd.append('file', file);
const r = await fetch('https://filepost.dev/v1/upload', {
method: 'POST',
headers: { 'X-API-Key': process.env.FILEPOST_API_KEY! },
body: fd,
cache: 'no-store',
});
if (!r.ok) return { error: `Upload failed (${r.status})` };
const data = await r.json();
return { url: data.url as string };
}
// app/upload/page.tsx
import { uploadAction } from './actions';
export default function UploadPage() {
return (
<form action={uploadAction}>
<input type="file" name="file" required />
<button type="submit">Upload</button>
</form>
);
}
Two files. ~25 lines. The key never enters the browser bundle. Type-safe end-to-end. Plays nicely with progressive enhancement — works without JS too.
Pages Router / API route version
Same shape if you're on Pages Router or need a callable endpoint:
// pages/api/upload.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import formidable from 'formidable';
import fs from 'node:fs';
export const config = { api: { bodyParser: false } };
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const form = formidable();
const [, files] = await form.parse(req);
const file = Array.isArray(files.file) ? files.file[0] : files.file;
if (!file) return res.status(400).json({ error: 'No file' });
const fd = new FormData();
fd.append('file', new Blob([fs.readFileSync(file.filepath)]), file.originalFilename!);
const r = await fetch('https://filepost.dev/v1/upload', {
method: 'POST',
headers: { 'X-API-Key': process.env.FILEPOST_API_KEY! },
body: fd,
});
res.status(r.status).json(await r.json());
}
FilePost vs uploadthing vs S3 + presigned URLs — for Next.js
| What you do | FilePost | uploadthing | S3 + presigned |
|---|---|---|---|
| Lines of Next.js code | ~25 | ~30 (provider + file-router) | ~50 (signing endpoint + client) |
| Framework lock | None | Strong (Next.js / RSC) | None |
| Server Action support | Native | Yes | Yes (via wrapper) |
| API key stays server-side | Yes | Yes | Yes |
| Permanent URL | Yes | Yes | No (object key) |
| Works on Vercel hobby | Yes | Yes | Yes |
| next/image-compatible URL | Yes (add to remotePatterns) | Yes | Depends on CloudFront |
| Free tier | 300 / mo, no card | 2 GB total, no card | 5 GB / 20k req |
| Paid plan | $9 / mo flat | $10 / mo | ~$0.023 / GB |
| Move to Remix later | No code change | Rewrite | No code change |
The opinion
uploadthing is the most opinionated DX for Next.js — and that's the case for it and against it. The case for: typed file-router, hooks, drop-in provider, perfect Vercel integration. The case against: you're now coupled to Next.js. If you ever migrate to Remix, Astro, or a non-React stack, the upload layer comes apart.
FilePost is the boring middle path: a flat HTTP endpoint that doesn't know what framework you're using. Less magic, less coupling, and the same flat $9/mo wherever you go.
next.config.js for next/image
If you render uploaded images via <Image />, allowlist FilePost's CDN domain so Next.js will optimize it:
// next.config.js
module.exports = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'filepost.dev', pathname: '/v1/file/**' },
],
},
};
When NOT to use FilePost from Next.js
- You want zero config and you're staying on Vercel forever — uploadthing is the better DX bet.
- You need server-side image transforms (resize, crop, watermark) baked into the upload pipeline. Cloudinary or Uploadcare do that better.
- You're proxying very large files (>4.5 MB) through a Vercel hobby function. Either upload directly from the browser (skip the function), upgrade to Vercel Pro, or move heavy upload routes to a different runtime.