How to scan file uploads in Next.js (App Router)
Next.js App Router introduces Route Handlers — TypeScript files placed at
app/api/<route>/route.ts that handle HTTP requests on the
server. Adding virus scanning to a Route Handler that accepts file uploads
requires a small amount of bridging work: the Web API File object
the browser sends has no disk path, so you must write it to a temporary file
before pompelmi can scan it.
This guide shows the complete pattern: receive the upload via FormData,
write to /tmp, scan with pompelmi, and clean up — regardless of
whether the scan passes or fails.
Why this requires the Node.js runtime
pompelmi spawns a clamscan subprocess (or connects to a clamd TCP
socket). Both of these require Node.js APIs — child_process and
net — that are not available in Next.js's Edge Runtime.
The default runtime for Route Handlers is already Node.js, so
no extra configuration is required in most projects. If your Next.js config
sets runtime: 'edge' globally, override it at the route level:
// app/api/upload/route.ts export const runtime = 'nodejs'; // required for pompelmi
Install
npm install pompelmi
Also install ClamAV on your server if you haven't already:
Linux (Debian / Ubuntu)sudo apt-get install -y clamav && sudo freshclammacOS
brew install clamav && freshclam
Complete Route Handler
Create the file at app/api/upload/route.ts. The handler:
- Reads the
FormDatafrom the request. - Converts the Web API
Fileto aBuffer. - Writes the buffer to a unique path in
/tmp. - Calls
scan()and inspects the verdict. - Deletes the temp file in a
finallyblock.
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { scan, Verdict } from 'pompelmi';
import { writeFile, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomBytes } from 'crypto';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
let tmpPath: string | null = null;
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file || file.size === 0) {
return NextResponse.json({ error: 'No file provided.' }, { status: 400 });
}
// Enforce a reasonable size limit before writing to disk
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'File too large.' }, { status: 413 });
}
// Web API File → Buffer → temp file on disk
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const ext = file.name.split('.').pop()?.toLowerCase() ?? 'bin';
tmpPath = join(tmpdir(), randomBytes(16).toString('hex') + '.' + ext);
await writeFile(tmpPath, buffer);
// Scan the temp file
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) {
return NextResponse.json(
{ error: 'Malware detected. Upload rejected.' },
{ status: 400 }
);
}
if (verdict === Verdict.ScanError) {
return NextResponse.json(
{ error: 'Scan could not complete. Upload rejected as a precaution.' },
{ status: 422 }
);
}
// Verdict.Clean — move to permanent storage here
// e.g. await uploadToS3(buffer, file.name);
return NextResponse.json({ status: 'ok', name: file.name });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
} finally {
// Always remove the temp file
if (tmpPath && existsSync(tmpPath)) {
await unlink(tmpPath).catch(() => {});
}
}
}
Temp file handling
The finally block ensures the temp file is deleted whether the
scan succeeds, fails, or throws. There are two additional precautions worth
taking in production:
-
Unique filenames. Using
randomBytes(16).toString('hex')as the base name prevents two concurrent requests from colliding on the same path. -
Size check before writing. The
file.sizecheck happens before any disk I/O, so oversized uploads are rejected without filling/tmp.
/tmp quotas.
For Next.js deployed on Vercel, serverless functions have a read-only
filesystem except for /tmp — and file system access is restricted.
Self-hosted Next.js on EC2 or a VPS does not have this limitation.
TypeScript types for pompelmi
pompelmi does not ship type declarations. Add this file to your project so TypeScript understands the API:
src/types/pompelmi.d.tsdeclare module 'pompelmi' {
interface ScanOptions {
host?: string;
port?: number;
timeout?: number;
}
const Verdict: Readonly<{
Clean: unique symbol;
Malicious: unique symbol;
ScanError: unique symbol;
}>;
type ScanVerdict = typeof Verdict[keyof typeof Verdict];
function scan(filePath: string, options?: ScanOptions): Promise<ScanVerdict>;
export { scan, Verdict };
}
Ensure your tsconfig.json includes the src/types
directory in typeRoots or include.
Minimal client component
A simple React component that posts to the Route Handler and displays the result:
'use client';
import { useState, useRef } from 'react';
export default function UploadForm() {
const [status, setStatus] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const file = inputRef.current?.files?.[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
setStatus('Scanning…');
const res = await fetch('/api/upload', { method: 'POST', body: form });
const data = await res.json();
setStatus(res.ok ? `Uploaded: ${data.name}` : `Error: ${data.error}`);
}
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} type="file" name="file" required />
<button type="submit">Upload & scan</button>
{status && <p>{status}</p>}
</form>
);
}
Next steps
- Storing files in the cloud after scanning? See Scanning files before uploading to AWS S3 or Scanning files before uploading to Cloudflare R2.
- Running in Docker or Kubernetes? See Running pompelmi with ClamAV in Docker Compose or Setting up pompelmi with ClamAV on Kubernetes for the TCP-mode setup.
- Want a complete upload security checklist beyond virus scanning? Read Node.js file upload security checklist.