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/remix pompelmi

Install ClamAV on your server:

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

Using @pompelmi/remix (recommended)

Since pompelmi v1.16.0, the official @pompelmi/remix package ships a ready-made pompelmiUploadHandler that plugs directly into unstable_parseMultipartFormData. Malicious files throw an HTTP 422 Response automatically — Remix catches it and returns it to the client.

// app/routes/upload.tsx
import { unstable_parseMultipartFormData, json } from '@remix-run/node';
import { pompelmiUploadHandler } from '@pompelmi/remix';
import type { ActionFunctionArgs } from '@remix-run/node';

export async function action({ request }: ActionFunctionArgs) {
  // Throws HTTP 422 automatically if malware is detected
  const formData = await unstable_parseMultipartFormData(
    request,
    pompelmiUploadHandler({ host: 'localhost', port: 3310 })
  );

  const file = formData.get('file') as File;
  if (!file) return json({ error: 'No file provided.' }, { status: 400 });

  return json({ status: 'ok', name: file.name, size: file.size });
}
Chain pompelmiUploadHandler with an inner handler to write clean files to disk: pompelmiUploadHandler({ inner: unstable_createFileUploadHandler({ directory: '/tmp' }), ... })

You can also use pompelmi directly without the integration package — read on for the manual approach.

Manual upload handler (without @pompelmi/remix)

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 { tmpdir } from 'os';

export const tempFileUploadHandler = unstable_createFileUploadHandler({
  directory: tmpdir(),
  maxPartSize: 50 * 1024 * 1024, // 50 MB
  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