Skip to content

Validate file uploads in Next.js, Hono and Remix

Any framework that gives you a standard Request can read the upload with formData():

import { checkUpload, type UploadRejection } from 'upload-safety';
const MAX_BYTES = 10 * 1024 * 1024;
export async function POST( request: Request ) {
const file = ( await request.formData() ).get( 'file' );
if ( ! ( file instanceof File ) ) {
return Response.json( { error: 'No file.' }, { status: 400 } );
}
const result = checkUpload(
{ filename: file.name, mimeType: file.type, bytes: new Uint8Array( await file.arrayBuffer() ) },
{ maxBytes: MAX_BYTES, allowedExtensions: [ 'pdf', 'docx', 'jpg', 'jpeg', 'png' ] }
);
if ( ! result.ok ) {
return Response.json( { error: MESSAGES[ result.reason ] }, { status: 422 } );
}
// Store result.bytes under result.filename with result.mimeType.
return Response.json( { name: result.filename } );
}
const MESSAGES: Record<UploadRejection, string> = {
'empty': 'The file is empty.',
'too-large': 'The file is too large.',
'type-not-allowed': 'This file type is not allowed.',
'mime-mismatch': 'The file type does not match its extension.',
'content-mismatch': 'The file content does not match its extension.',
'macros': 'Files with macros or other active content are not allowed.',
'encrypted': 'Password-protected files cannot be checked.',
};

When the client sends the size up front, for example to get a presigned upload URL, validateUploadDescriptor runs the size, extension and MIME checks without the bytes:

import { validateUploadDescriptor } from 'upload-safety';
const descriptor = validateUploadDescriptor( { filename, mimeType, size }, { maxBytes: MAX_BYTES } );
if ( ! descriptor.ok ) {
return Response.json( { error: descriptor.reason }, { status: 422 } );
}

This does not replace checkUpload once the bytes arrive. A client can declare anything.