nuxt-filer
v0.0.16
Published
File storage module for Nuxt
Readme
nuxt-filer
File storage module for Nuxt. Provides a server-side useFileStorage() composable with pluggable storage backends — from zero-config local filesystem to custom providers with separate metadata databases and external file sync.
Features
- Pluggable provider architecture — use the built-in unstorage provider or bring your own (Prisma, Drizzle, etc.)
- File versioning — built-in version tracking, latest-version filtering, and duplicate detection
- External file sync — two-way sync with external systems (Jira, SharePoint, etc.) via optional provider interface
- Zero-config default — works out of the box with local filesystem storage, no database required
- Auto-imported —
useFileStorage(), types, and provider utilities are auto-imported in server context - Group-based organization — files are organized by
groupId(project, ticket, order, etc.) @nuxt/imageintegration — when@nuxt/imageis installed, an IPX endpoint is wired up automatically so<NuxtImg provider="filer" src="<groupId>/<id>" />returns optimized variants of stored files- Upload-time image processing — optionally run images through Sharp when storing them (resize, format-convert, optimize, preserve animation) via a per-call
transformoption or the standalonetransformImage()util - Resumable uploads (tus) — opt-in tus endpoint backed by
@tus/server, a client-sideuseTusUpload()composable, anduseTusStaging().promote()to move finished uploads into the file storage
Quick Setup
npx nuxi module add nuxt-filerConfiguration
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-filer'],
filer: {
// Nitro storage mount name (default: 'documents')
storageName: 'documents',
// Base path for fs-lite driver (default: '.data/documents')
storagePath: '.data/documents',
// 'unstorage' (built-in) or 'custom' (bring your own provider)
provider: 'unstorage',
},
})Usage
Basic — Upload and retrieve files
// server/api/files/[groupId].post.ts
export default defineEventHandler(async (event) => {
const groupId = getRouterParam(event, 'groupId')!
const body = await readMultipartFormData(event)
const file = body![0]!
const storage = useFileStorage()
const id = await storage.upload(groupId, file.data, {
meta: {
name: file.filename || 'unnamed',
mime: file.type || 'application/octet-stream',
type: 'document',
version: 1,
},
})
return { id, groupId }
})// server/api/files/[groupId].get.ts
export default defineEventHandler(async (event) => {
const groupId = getRouterParam(event, 'groupId')!
const storage = useFileStorage()
return await storage.list(groupId)
})Upload-time image processing
Pass a transform option to upload() to process an image before it is stored — useful for normalizing user uploads or rehosted remote images to a capped size and compact format. The processed bytes are what gets stored, and the file's meta.mime / meta.width / meta.height are updated to match the result.
const id = await storage.upload(groupId, file.data, {
meta: { name: file.filename!, mime: file.type!, type: 'image', version: 1 },
transform: {
width: 128,
height: 128,
format: 'webp', // convert to webp
// fit: 'inside' (default), withoutEnlargement: true (default),
// quality, background, animated (default true)
},
})You can also call the util directly (e.g. for images fetched server-side):
const res = await transformImage(buffer, { width: 64, format: 'webp' })
// res: { data: Buffer, mime: 'image/webp', format: 'webp', width: 64, height: 64 }transform / transformImage() options
| Option | Description |
|---|---|
| width, height | Target box in px (combined with fit) |
| fit | Resize fit mode (inside default, or cover/contain/fill/outside) |
| withoutEnlargement | Never scale up beyond the original (default true) |
| format | Output format: webp / png / jpeg / avif / gif (default: keep input) |
| quality | Output quality 1-100 for lossy formats |
| animated | Preserve all frames of animated inputs (default true; only retained when format is webp/gif) |
| background | Background used when flattening transparency |
Image processing requires the optional
sharppeer dependency. Install it (npm i sharp) only if you usetransform/transformImage()— calling them withoutsharpthrows a clear error. Without atransform,upload()stores the raw bytes unchanged and needs no extra dependency.
useFileStorage() API
| Method | Description |
|---|---|
| upload(groupId, data, options?) | Store a file, returns its ID. options.transform runs the bytes through Sharp first (see above) |
| list(groupId) | List all files in a group |
| get(groupId, id) | Get a file with data and metadata |
| getData(groupId, id) | Get raw binary data only |
| getMeta(id) | Get metadata only |
| updateMeta(id, meta) | Deep-merge metadata update |
| remove(groupId, id) | Delete a file |
| clear(groupId) | Delete all files in a group |
| has(groupId, id) | Check if a file exists |
| findByMeta(key, value, groupId?) | Find a file by metadata field |
| checkDuplicate(groupId, key, value) | Check if a duplicate exists |
| getLatestVersions(groupId) | Get latest version of each file by name |
| getNextVersionNumber(files, name) | Calculate next version number |
| external?.sync(groupId, id) | Sync with external system (if provider supports it) |
| external?.push(groupId, id, data, meta) | Push to external system |
| external?.pull(groupId, ref) | Pull from external system |
Serving raw files with sendStoredFile()
The IPX route serves images with full HTTP caching. For everything else — original PDFs, non-image downloads, the unprocessed bytes of any file — sendStoredFile() streams a stored file back through an H3 event with the same revalidation story.
// server/api/files/[groupId]/[id].get.ts
export default defineEventHandler((event) => {
const { groupId, id } = getRouterParams(event)
return sendStoredFile(event, groupId, id) // 404 if missing
})It sets content-type from meta.mime, a content-disposition filename from meta.name, and cache-control / last-modified / etag, honoring if-modified-since / if-none-match (304) — just like the IPX route. HEAD requests get the headers without a body.
sendStoredFile(event, groupId, id, {
disposition: 'attachment', // force a download ('inline' is the default)
filename: 'invoice-2026.pdf', // override the download name (default: meta.name)
maxAge: 3600, // cache-control max-age in seconds (default: 1 year; 0 = no-cache)
})sendStoredFile is auto-imported in your server routes — no need to import it.
@nuxt/image Integration
If @nuxt/image is installed alongside nuxt-filer, the module automatically registers a filer image provider and an IPX endpoint that pulls bytes from your storage provider, runs them through Sharp, and returns the result.
<template>
<NuxtImg
provider="filer"
:src="`${groupId}/${fileId}`"
width="200"
height="200"
fit="cover"
format="webp"
/>
</template>Generated URLs look like /_filer-ipx/w_200,h_200,fit_cover,format_webp/<groupId>/<fileId> and are served with cache-control: max-age=..., last-modified, and etag for if-modified-since / if-none-match revalidation.
The integration can be configured or turned off:
filer: {
image: {
enabled: true, // false to disable; 'force' to register without @nuxt/image
route: '/_filer-ipx', // base path for the IPX endpoint
providerName: 'filer', // name used in <NuxtImg provider="..." />
},
},@nuxt/image and ipx are declared as optional peer dependencies — they only need to be installed if you want to use this integration.
Resumable uploads (tus)
Large or flaky-network uploads can use the tus protocol instead of a single multipart POST. Uploads are staged chunk-by-chunk into a local directory (survives connection drops and page reloads), then promoted into the regular file storage by one of your own server routes — which is where you enforce auth and attach domain metadata.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-filer'],
filer: {
tus: {
enabled: true,
route: '/_filer-tus', // default
stagingDir: '.data/tus', // default
maxSize: 500 * 1024 * 1024, // optional, bytes
expiration: 24 * 60 * 60 * 1000, // optional: purge stale staged uploads
},
},
})Client side, the auto-imported useTusUpload() composable wraps
tus-js-client with reactive state:
<script setup lang="ts">
const tus = useTusUpload({
metadata: (file) => ({ comment: 'from the web app' }), // extra tus metadata
// cleanupOnPageHide: true, // sendBeacon-delete staged uploads on close
})
function onSelect(e: Event) {
tus.add(Array.from((e.target as HTMLInputElement).files ?? []))
}
async function save() {
for (const item of tus.completed.value) {
await $fetch('/api/documents/finalize', {
method: 'POST',
body: { tusId: item.tusId, name: item.file.name },
})
}
tus.clear()
}
</script>Each entry in tus.items tracks progress, complete, tusId, and error;
tus.remove(name) aborts and deletes a staged upload, tus.cancel() discards
everything. Interrupted uploads resume automatically (retry backoff, restart
on online, and — via the tus fingerprint — across page reloads).
Server side, promote a finished upload into the file storage:
// server/api/documents/finalize.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { id, meta } = await useTusStaging().promote(body.tusId, 'my-group', {
meta: { type: 'document' }, // merged over tus filename/filetype
// transform: { width: 1600 }, // optional sharp processing
})
return { id, meta }
})useTusStaging() also exposes info(), read(), and remove() for staged
uploads. To protect or customize the endpoint itself (auth, upload hooks, a
different datastore), configure the underlying @tus/server from a Nitro
plugin — the server is created lazily on the first request:
// server/plugins/tus.ts
export default defineNitroPlugin(() => {
setTusServerOptions({
async onIncomingRequest(req) {
// throw { status_code: 401, body: 'Unauthorized' } to reject
},
})
})Notes:
- The endpoint handles the full tus lifecycle (create/HEAD/PATCH/DELETE); a
POST <route>/cleanupsub-route accepts{ tusIds: string[] }fromnavigator.sendBeaconfor page-close cleanup (used bycleanupOnPageHide). - Staging uses
@tus/file-storeon the local filesystem, independent of the configured storage provider — promotion works with any provider, including S3 and custom ones.
S3 storage
For durable object storage (AWS S3, Cloudflare R2, MinIO, …) use the built-in
createS3Provider. It stores each file as a data object plus a JSON metadata
sidecar, and — unlike unstorage's generic s3 driver — list()/findByMeta()
use real S3 prefix listing with continuation pagination, so a group stays
correctly scoped even inside a large or shared bucket.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-filer'],
filer: { provider: 'custom' },
})// server/plugins/file-provider.ts
export default defineNitroPlugin(() => {
const { s3 } = useRuntimeConfig()
setFileStorageProvider(createS3Provider({
accessKeyId: s3.accessKeyId,
secretAccessKey: s3.secretAccessKey,
endpoint: s3.endpoint, // e.g. https://<acct>.r2.cloudflarestorage.com
region: s3.region, // R2: 'auto'
bucket: s3.bucket,
// prefix: 'media/', // optional: namespace within a shared bucket
}))
})
createS3Providerrequires the optionalaws4fetchpeer dependency (npm i aws4fetch). Pass a customclientto use a different transport or to unit-test without network.
Custom Provider
For advanced use cases (database-backed metadata, external file sync), implement the FileStorageProvider interface and register it in a Nitro plugin:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-filer'],
filer: {
provider: 'custom',
},
})// server/plugins/file-provider.ts
export default defineNitroPlugin(() => {
setFileStorageProvider({
async create(groupId, data, meta) {
// Store binary data (e.g. S3, unstorage)
// Store metadata (e.g. Prisma, Drizzle)
return { id: '...' }
},
async get(groupId, id) { /* ... */ },
async getData(groupId, id) { /* ... */ },
async getMeta(id) { /* ... */ },
async list(groupId) { /* ... */ },
async update(id, meta) { /* ... */ },
async remove(groupId, id) { /* ... */ },
async clear(groupId) { /* ... */ },
async has(groupId, id) { /* ... */ },
async findByMeta(filter) { /* ... */ },
// Optional: external file sync
external: {
async sync(groupId, id) { /* ... */ },
async push(groupId, id, data, meta) { /* ... */ },
async pull(groupId, externalRef) { /* ... */ },
},
})
})Provider interface
interface FileStorageProvider {
create(groupId: string, data: Buffer | Uint8Array, meta?: FileMeta): Promise<{ id: string }>
get(groupId: string, id: string): Promise<StoredFile | null>
getData(groupId: string, id: string): Promise<Buffer | null>
getMeta(id: string): Promise<FileMeta | null>
list(groupId: string): Promise<StoredFile[]>
update(id: string, meta: Partial<FileMeta>): Promise<void>
remove(groupId: string, id: string): Promise<void>
clear(groupId: string): Promise<void>
has(groupId: string, id: string): Promise<boolean>
findByMeta(filter: { key: string; value: unknown; groupId?: string }): Promise<StoredFile | null>
external?: FileStorageExternalProvider
}Types
interface FileMeta {
name: string
mime: string
type: string
version: number
username?: string
comment?: string
[key: string]: unknown // extend with your own fields
}
interface StoredFile {
id: string
groupId: string
data?: Buffer
meta: FileMeta
external?: ExternalRef
createdAt?: Date
updatedAt?: Date
}
interface ExternalRef {
source: string // e.g. 'jira', 'sharepoint'
externalId: string
externalUrl?: string
cachedAt?: Date
}Contribution
# Install dependencies
pnpm install
# Generate type stubs
pnpm run dev:prepare
# Develop with the playground
pnpm run dev
# Run ESLint
pnpm run lint
# Run Vitest
pnpm run test
# Release new version
pnpm run release