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.

New to pompelmi? Read Getting started with antivirus scanning in Node.js first to install ClamAV, then come back for the Next.js integration.

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 freshclam
macOS
brew install clamav && freshclam

Complete Route Handler

Create the file at app/api/upload/route.ts. The handler:

  1. Reads the FormData from the request.
  2. Converts the Web API File to a Buffer.
  3. Writes the buffer to a unique path in /tmp.
  4. Calls scan() and inspects the verdict.
  5. Deletes the temp file in a finally block.
// 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.size check happens before any disk I/O, so oversized uploads are rejected without filling /tmp.
Lambda and some container environments have small /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.ts
declare 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