Secure File Upload API in Next.js-Project Overview
Modern web applications often need to accept user‑generated files — avatars, documents, images, or videos. Handling uploads securely requires authentication, strict validation, and a reliable storage backend. This tutorial shows how to combine Next.js API routes, NextAuth.js for session management, and Cloudinary for scalable media delivery.
Prerequisites
- Node.js 18+ installed
- A Next.js 14 (App Router) project created with
create-next-app - Accounts on NextAuth.js and Cloudinary
- Basic familiarity with TypeScript, React, and REST APIs
Setting Up NextAuth
NextAuth handles user sessions via secure HTTP‑only cookies. We’ll use the credentials provider for simplicity, but any provider works.
1. Install Dependencies
npm i next-auth@beta cloudinary formidable2. Configure Authentication Options
Create lib/auth.ts (or .js if you prefer plain JS).
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import { compare } from "bcryptjs"
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null
const user = await prisma.user.findUnique({ where: { email: credentials.email } })
if (!user) return null
const isValid = await compare(credentials.password, user.passwordHash)
if (!isValid) return null
return { id: user.id, email: user.email, name: user.name }
},
}),
],
session: { strategy: "jwt" },
callbacks: {
async jwt({ token, user }) {
if (user) token.id = user.id
return token
},
async session({ session, token }) {
if (session.user) (session.user as any).id = token.id
return session
},
},
pages: { signIn: "/auth/signin" },
})
3. Add API Route Handlers
In app/api/auth/[...nextauth]/route.ts export the handlers:
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlersCreating the Upload API Route
Next.js App Router uses route handlers under app/api/. We’ll create app/api/upload/route.ts.
1. Parse Multipart Form Data
Use formidable (or the native Request.formData() in Next 14) to read files. The example below uses formidable for streaming large files.
import { IncomingForm, Fields, Files } from "formidable"
import { Readable } from "stream"
import { NextRequest, NextResponse } from "next/server"
function parseForm(req: NextRequest): Promise {
const form = new IncomingForm({ multiples: false, keepExtensions: true })
const contentType = req.headers.get("content-type") ?? ""
const buffer = await req.arrayBuffer()
const stream = Readable.from(Buffer.from(buffer))
// @ts-ignore - attach headers for formidable
stream.headers = { "content-type": contentType }
return new Promise((resolve, reject) => {
form.parse(stream, (err, fields, files) => {
if (err) reject(err)
else resolve({ fields, files })
})
})
}2. Validate the Session
Only authenticated users may upload. Use auth() from NextAuth.
import { auth } from "@/lib/auth"
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
3. File Validation Rules
- Allowed MIME types:
image/jpeg,image/png,application/pdf - Maximum size: 5 MB (adjust per requirement)
- Sanitize filename to avoid path traversal
const ALLOWED_TYPES = ["image/jpeg", "image/png", "application/pdf"]
const MAX_SIZE = 5 * 1024 * 1024 // 5 MB
const file = files.file?.[0] // assuming single file field named "file"
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.mimetype ?? "")) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: "File exceeds size limit" }, { status: 400 })
}
// Optional: rename to a safe UUID
import { v4 as uuidv4 } from "uuid"
const safeName = `${uuidv4()}-${file.originalFilename?.replace(/[^a-zA-Z0-9._-]/g, "")}`
Uploading to Cloudinary
Cloudinary offers signed uploads, automatic transformations, and CDN delivery. Store credentials in environment variables.
1. Environment Variables
# .env.local
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret2. Cloudinary Helper
import { v2 as cloudinary } from "cloudinary"
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
})
export async function uploadToCloudinary(
filePath: string,
options: Record = {}
) {
return cloudinary.uploader.upload(filePath, {
resource_type: "auto",
folder: "user_uploads",
use_filename: true,
unique_filename: false,
...options,
})
}3. Perform the Upload
import { uploadToCloudinary } from "@/lib/cloudinary"
import fs from "fs/promises"
const uploadResult = await uploadToCloudinary(file.filepath, {
public_id: safeName,
context: `userId=${session.user.id}`, // useful for later queries
})
// Clean up temporary file
await fs.unlink(file.filepath).catch(() => {})
return NextResponse.json({
url: uploadResult.secure_url,
publicId: uploadResult.public_id,
format: uploadResult.format,
width: uploadResult.width,
height: uploadResult.height,
})
Securing the Endpoint
Rate Limiting
Prevent abuse by limiting uploads per user. A simple in‑memory store works for a single instance; for production use Redis.
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 uploads per minute
})
const { success } = await ratelimit.limit(session.user.id)
if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 })
}
Content‑Security‑Policy & CORS
Set appropriate headers in next.config.js or via middleware.
// next.config.js
module.exports = {
async headers() {
return [
{
source: "/api/upload",
headers: [
{ key: "Content-Security-Policy", value: "default-src 'self'; img-src data: https://res.cloudinary.com;" },
{ key: "Access-Control-Allow-Origin", value: process.env.NEXT_PUBLIC_APP_URL ?? "*" },
{ key: "Access-Control-Allow-Methods", value: "POST, OPTIONS" },
{ key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization" },
],
},
]
},
}
Virus Scanning (Optional)
Integrate a service like ClamAV or a cloud‑based scanner before forwarding to Cloudinary. This step is omitted for brevity but recommended for public upload endpoints.
Handling Errors and Responses
- Return consistent JSON shape:
{ data?, error? } - Log server‑side errors with a structured logger (e.g., Pino) but never expose stack traces to the client.
- Use HTTP status codes: 400 for validation, 401/403 for auth, 429 for rate limit, 500 for unexpected failures.
try {
// … validation & upload logic
} catch (err) {
console.error("Upload error:", err)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
Testing the Integration
Unit Tests
Mock auth(), formidable, and Cloudinary SDK. Verify that unauthorized requests receive 401, oversized files receive 400, and successful uploads return a Cloudinary URL.
End‑to‑End Tests
Use Cypress or Playwright to simulate a logged‑in user posting a multipart form to /api/upload. Assert the response contains a valid https://res.cloudinary.com/… URL.
Manual Verification
- Start the dev server:
npm run dev - Sign in via the NextAuth sign‑in page.
- Use a tool like Postman or a simple HTML form targeting
/api/uploadwithenctype="multipart/form-data". - Confirm the file appears in your Cloudinary Media Library under the
user_uploadsfolder.
Deployment Considerations
- Vercel: API routes run as serverless functions with a 4.5 MB request body limit. For larger files, stream directly to Cloudinary using signed upload URLs (client‑side upload) instead of proxying through the API.
- Docker / Kubernetes: Increase the body parser limit (
bodyParser: { sizeLimit: '10mb' }innext.config.js) and ensure the container has enough ephemeral storage for temporary files. - Environment Secrets: Store Cloudinary credentials in the platform’s secret manager; never commit
.env.local. - Monitoring: Add Sentry or Datadog to capture upload failures and latency spikes.
Common Pitfalls and How to Avoid Them
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Uploads hang or time out | Large files buffered in memory | Stream to disk with formidable or use client‑side signed uploads |
| Unauthenticated requests succeed | Forgot to call auth() before processing | Guard the handler early; return 401 if no session |
| File type bypass | Only checking extension, not MIME | Validate file.mimetype and optionally inspect magic bytes |
| Cloudinary errors not surfaced | Swallowing promise rejections | Wrap upload in try/catch and return structured error |
| Rate limiter ineffective in serverless | In‑memory store resets per invocation | Use Redis‑backed limiter (Upstash, Vercel KV, etc.) |
Conclusion
You now have a fully functional, secure Next.js file upload endpoint that authenticates users with NextAuth, validates every payload, stores assets on Cloudinary, and protects against abuse. The pattern scales: swap the storage provider, add virus scanning, or move to client‑side signed uploads for very large files. Keep security hygiene — rotate API secrets, monitor logs, and enforce least‑privilege access on Cloudinary folders.
Happy coding!