Skip to content

Instantly share code, notes, and snippets.

@ChrisGV04
Last active July 13, 2025 00:53
Show Gist options
  • Save ChrisGV04/8350b53958f207ef2d33a9ea96ecd882 to your computer and use it in GitHub Desktop.
Save ChrisGV04/8350b53958f207ef2d33a9ea96ecd882 to your computer and use it in GitHub Desktop.
Small example on how to do file uploads on Nuxt 3 & Nitro. I believe it requires Node v20+. Heavily inspired by NuxtHub
<script setup lang="ts">
async function uploadFiles(e: Event) {
const input = e.target as HTMLInputElement;
const files = input.files;
if (!files) return;
const fd = new FormData();
for (const file of files) {
fd.append('files', file, file.name);
}
try {
// Send files as FormData
await $fetch('/api/files/bulk', { method: 'POST', body: fd });
} catch (error) {
console.log('Error uploading files:', error);
}
}
</script>
<template>
<input multiple type="file" @change="uploadFiles">
</template>
// File path: server/api/files/bulk.post.ts
import { handleFileUpload, receiveFiles } from '~/server/utils/blob';
/**
* Event handler to receive multiple files
*/
export default defineEventHandler(async (event) => {
const files = await receiveFiles(event, {
multiple: 20, // Max 20 files at a time for now
ensure: {
maxSize: '50MB', // Max 50 MB each file
types: ['audio', 'csv', 'image', 'video', 'pdf', 'text'],
},
});
for (const file of files) {
await handleFileUpload(file, true);
}
return sendNoContent(event);
});
// File path: server/api/files/index.post.ts
import { receiveFiles, handleFileUpload } from '~/server/utils/blob';
/**
* Event handler to receive a single file
*/
export default defineEventHandler(async (event) => {
const [file] = await receiveFiles(event, {
formKey: 'file',
multiple: false,
ensure: {
maxSize: '128MB',
types: ['audio', 'csv', 'image', 'video', 'pdf', 'text'],
},
});
await handleFileUpload(file);
return sendNoContent(event);
});
// File path: server/types/blob.ts
///////////////////////////////////////////////////////////
// Functionality based on Nuxt Hub's Blob implementation //
///////////////////////////////////////////////////////////
// Credits from shared utils of https://github.com/pingdotgg/uploadthing
export type FileSizeUnit = 'B' | 'KB' | 'MB' | 'GB';
export type BlobSize = `${number}${FileSizeUnit}`;
export type BlobType =
| 'image'
| 'video'
| 'audio'
| 'pdf'
| 'csv'
| 'text'
| 'blob'
| (string & Record<never, never>);
export interface BlobUploadOptions {
/**
* The key to get the file/files from the request form.
* @default 'files'
*/
formKey?: string;
/**
* Whether to allow multiple files to be uploaded.
* @default true
*/
multiple?: boolean | number;
/**
* Options used for the ensure() method.
*/
ensure?: BlobEnsureOptions;
}
export interface BlobEnsureOptions {
/**
* The maximum size of the blob (e.g. '1MB')
*/
maxSize?: BlobSize;
/**
* The allowed types of the blob (e.g. ['image/png', 'application/json', 'video'])
*/
types?: BlobType[];
}
// File path: server/utils/blob.ts
import type { H3Event } from 'h3';
import type { BlobUploadOptions, BlobEnsureOptions, BlobSize, FileSizeUnit } from '~/server/types/blob';
import { defu } from 'defu';
import mime from 'mime';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { pipeline } from 'node:stream';
import util from 'node:util';
import { parse } from 'pathe';
// Credits from shared utils of https://github.com/pingdotgg/uploadthing
const FILESIZE_UNITS = ['B', 'KB', 'MB', 'GB'];
/**
* Helper function that converts any valid BlobSize into numeric bytes value
*
* @example "1MB", "1500B", "1.2GB"
*
* @throws If the input is not a valid BlobSize
*/
export function fileSizeToBytes(input: BlobSize) {
const regex = new RegExp(`^(\\d+)(\\.\\d+)?\\s*(${FILESIZE_UNITS.join('|')})$`, 'i');
const match = input.match(regex);
if (!match) {
throw createError({
statusCode: 500,
message: `Invalid file size format: ${input}`,
});
}
const sizeValue = Number.parseFloat(match[1]);
const sizeUnit = match[3].toUpperCase() as FileSizeUnit;
if (!FILESIZE_UNITS.includes(sizeUnit)) {
throw createError({
statusCode: 500,
message: `Invalid file size unit: ${sizeUnit}`,
});
}
const bytes = sizeValue * Math.pow(1024, FILESIZE_UNITS.indexOf(sizeUnit));
return Math.floor(bytes);
}
/**
* Ensure the blob is valid and meets the specified requirements.
*
* @param blob The blob to check
* @param options The options to check against
* @param options.maxSize The maximum size of the blob (e.g. '1MB')
* @param options.types The allowed types of the blob (e.g. ['image/png', 'application/json', 'video'])
*
* @throws If the blob does not meet the requirements
*/
export function ensureBlob(blob: Blob, options: BlobEnsureOptions = {}) {
if (!(blob instanceof Blob)) {
throw createError({
statusCode: 400,
message: 'Received invalid file',
});
}
if (options.maxSize) {
const maxFileSizeBytes = fileSizeToBytes(options.maxSize);
if (blob.size > maxFileSizeBytes) {
throw createError({
statusCode: 400,
message: `File too heavy. Max size is: ${options.maxSize}`,
});
}
}
const [blobType, blobSubtype] = blob.type.split('/');
if (
options.types?.length &&
!options.types?.includes(blob.type) &&
!options.types?.includes(blobType) &&
!options.types?.includes(blobSubtype)
) {
throw createError({
statusCode: 400,
message: `Invalid file type. Only allowed: ${options.types.join(', ')}`,
});
}
}
/**
* Utility to receive a file or files from body's FormData without storing it.
*
* @throws
* If the files are invalid or don't meet the ensure conditions.
*/
export async function receiveFiles(event: H3Event, options: BlobUploadOptions = {}) {
options = defu(options, { formKey: 'files', multiple: true } satisfies BlobUploadOptions);
const form = await readFormData(event);
const files = form.getAll(options.formKey!) as File[];
if (!files?.length) throw createBadRequestError('No recibimos ningún archivo');
if (!options.multiple && files.length > 1) throw createBadRequestError('No se permiten múltiples archivos');
if (typeof options.multiple === 'number' && files.length > options.multiple)
throw createBadRequestError('Número de archivos excedido', `Máximo permitido: ${options.multiple}`);
if (options.ensure?.maxSize || options.ensure?.types?.length) {
for (const file of files) {
ensureBlob(file, options.ensure);
}
}
return files;
}
/** Simple utility to get the MIME type of a file */
export function getContentType(pathOrExtension?: string) {
return (pathOrExtension && mime.getType(pathOrExtension)) || 'application/octet-stream';
}
/** Inspired by Fastify's upload guide. Used to pipe the file stream into fs */
const pump = util.promisify(pipeline);
/** Generates a safe file name and stores the file on the disk */
async function handleFileUpload(file: File) {
const { ext } = parse(file.name);
/** Unique file name with current UNIX date, 16 random chracters and the original file extension */
const safeFileName = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
/** The path to the directory where you want to save the file in the local file system */
const filePath = path.resolve('public/uploads', safeFileName);
try {
/** Stream the file into the file system to save it */
await pump(file.stream() as any, fs.createWriteStream(filePath));
} catch (error) {
console.error('Error uploading file:', error);
/** Return error response to the client */
throw createError({ statusCode: 500, message: 'Error uploading file' });
}
///////////////////////////////////////////////////////////////////////////
// Do anything else you want here. The file is now saved at the filePath //
///////////////////////////////////////////////////////////////////////////
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment