Scanning file uploads in Remix / React Router v7

Remix (now unified with React Router v7) uses server-side Actions to handle form submissions, including file uploads. The framework provides unstable_parseMultipartFormData and custom upload handlers that let you control exactly where uploaded bytes go before your action logic runs.

The pattern for virus scanning: write the uploaded file to a temp path via a custom upload handler, scan it with pompelmi, and either proceed or return a validation error to the form.

New to pompelmi? Read Getting started with antivirus scanning in Node.js first to install ClamAV, then return here.

Install

npm install pompelmi

Install ClamAV on your server:

Linux (Debian / Ubuntu)
sudo apt-get install -y clamav && sudo freshclam

Custom upload handler

Remix's unstable_parseMultipartFormData accepts an upload handler — an async function that receives each part of the multipart request and returns a value stored in the resulting FormData. Write the file bytes to a temp path and return the path:

// app/utils/uploadHandler.server.ts
import { unstable_createFileUploadHandler } from '@remix-run/node';
import { join } from 'path';
import { tmpdir } from 'os';

export const tempFileUploadHandler = unstable_createFileUploadHandler({
  directory: tmpdir(),
  maxPartSize: 50 * 1024 * 1024, // 50 MB
  // avoidFileConflicts generates a unique filename
  avoidFileConflicts: true,
});
unstable_createFileUploadHandler writes each part to the directory you specify. The resulting form value is a NodeOnDiskFile object with a .filepath property — the absolute path on disk.

Route Action with virus scanning

// app/routes/upload.tsx  (server-side Action)
import {
  unstable_parseMultipartFormData,
  NodeOnDiskFile,
  ActionFunctionArgs,
  json,
} from '@remix-run/node';
import { tempFileUploadHandler } from '~/utils/uploadHandler.server';
import { scan, Verdict } from 'pompelmi';
import { unlink } from 'fs/promises';
import { existsSync } from 'fs';

export async function action({ request }: ActionFunctionArgs) {
  let tmpPath: string | null = null;

  try {
    const formData = await unstable_parseMultipartFormData(
      request,
      tempFileUploadHandler
    );

    const file = formData.get('file') as NodeOnDiskFile | null;

    if (!file) {
      return json({ error: 'No file provided.' }, { status: 400 });
    }

    tmpPath = file.filepath;

    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      return json({ error: 'Malware detected. Upload rejected.' }, { status: 400 });
    }

    if (verdict === Verdict.ScanError) {
      return json(
        { error: 'Scan could not complete. Upload rejected as a precaution.' },
        { status: 422 }
      );
    }

    // File is clean — move to permanent storage here
    // e.g. await uploadToS3(tmpPath, file.name);

    return json({ status: 'ok', name: file.name });

  } catch (err) {
    const message = err instanceof Error ? err.message : 'Upload failed';
    return json({ error: message }, { status: 500 });

  } finally {
    if (tmpPath && existsSync(tmpPath)) {
      await unlink(tmpPath).catch(() => {});
    }
  }
}

Route component

The component uses useActionData to display the scan result. The form works without JavaScript (progressive enhancement) and with it via Remix's <Form>:

// app/routes/upload.tsx  (client component, same file)
import { Form, useActionData, useNavigation } from '@remix-run/react';

export default function UploadPage() {
  const data       = useActionData<typeof action>();
  const navigation = useNavigation();
  const uploading  = navigation.state === 'submitting';

  return (
    <Form method="post" encType="multipart/form-data">
      <input type="file" name="file" required />
      <button type="submit" disabled={uploading}>
        {uploading ? 'Scanning…' : 'Upload'}
      </button>

      {data?.error  && <p style={{ color: 'red' }}>{data.error}</p>}
      {data?.status === 'ok' && <p>Uploaded: {data.name}</p>}
    </Form>
  );
}

Resource Route variant (JSON API)

If you prefer a JSON API endpoint without a UI component — for example, to back a JavaScript fetch call — use a resource route (a route file with no default export):

// app/routes/api.upload.ts
import {
  unstable_parseMultipartFormData,
  NodeOnDiskFile,
  ActionFunctionArgs,
  json,
} from '@remix-run/node';
import { tempFileUploadHandler } from '~/utils/uploadHandler.server';
import { scan, Verdict } from 'pompelmi';
import { unlink } from 'fs/promises';
import { existsSync } from 'fs';

export async function action({ request }: ActionFunctionArgs) {
  if (request.method !== 'POST') {
    return json({ error: 'Method not allowed.' }, { status: 405 });
  }

  let tmpPath: string | null = null;
  try {
    const formData = await unstable_parseMultipartFormData(request, tempFileUploadHandler);
    const file     = formData.get('file') as NodeOnDiskFile | null;
    if (!file) return json({ error: 'No file.' }, { status: 400 });

    tmpPath = file.filepath;
    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) return json({ error: 'Malware detected.' }, { status: 400 });
    if (verdict === Verdict.ScanError)  return json({ error: 'Scan incomplete.'  }, { status: 422 });

    return json({ status: 'ok', name: file.name });

  } finally {
    if (tmpPath && existsSync(tmpPath)) await unlink(tmpPath).catch(() => {});
  }
}

// No default export = resource route (no UI)
Resource routes are accessed at the URL that matches their filename. A file at app/routes/api.upload.ts is available at POST /api/upload.

Next steps