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.
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)
app/routes/api.upload.ts is available at
POST /api/upload.
Next steps
- Storing files in S3 after scanning? See Scanning files before uploading to AWS S3.
- Want the full upload security checklist? Read Node.js file upload security checklist.
- Running in Docker? See Running pompelmi with ClamAV in Docker Compose.