Advanced

Plugin Authoring

Create your own validators and processors with this comprehensive guide.

Plugin Authoring Guide

This guide covers everything you need to know to create custom plugins for Nuxt Upload Kit.

Architecture Overview

Nuxt Upload Kit uses a plugin-based architecture with three plugin types:

TypePurposeExampleDefine With
ValidatorGate files before addingMax file size, allowed typesdefineProcessingPlugin
ProcessorTransform filesThumbnails, compressiondefineProcessingPlugin
StorageHandle persistenceS3, Azure, FirebasedefineStorageAdapter

Plugin Lifecycle

┌─────────────────────────────────────────────────────────────────────┐
│                         ADDING FILES                                │
│                                                                     │
│   addFile() ──▶ [validate] ──▶ [preprocess] ──▶ file:added         │
│                     │                                               │
│                     ▼ (fail)                                        │
│                  file:error                                         │
└─────────────────────────────────────────────────────────────────────┘
                              │
                              │ upload() called
                              ▼
┌─────────────────────────────────────────────────────────────────────┐
│                         UPLOADING                                   │
│                                                                     │
│   [process] ──▶ storage.upload ──▶ [complete] ──▶ upload:complete  │
│       │              │                                              │
│       ▼ (fail)       ▼ (fail)                                       │
│    file:error     file:error                                        │
└─────────────────────────────────────────────────────────────────────┘

Legend: [hooks] are plugin extension points

When Each Hook Runs

HookWhenUse For
validateImmediately on addFile()Rejecting invalid files
preprocessAfter validation passesGenerating thumbnails, previews
processWhen upload() is calledCompression, transformations
completeAfter successful uploadPost-upload cleanup

Basic Structure

Use defineProcessingPlugin to create custom validators and file processors.

import { defineProcessingPlugin } from "nuxt-upload-kit"

const MyPlugin = defineProcessingPlugin<PluginOptions>((options) => ({
  id: "my-plugin",
  hooks: {
    // Available hooks: validate, preprocess, process, complete
  },
}))

Hook Reference

HookWhenPurposeReturn
validateBefore addingCheck if file is validfile or throw error
preprocessAfter validationImmediate UI prep (thumbnails)Modified file
processBefore uploadTransform file (compression)Modified file
completeAfter uploadPost-upload processingModified file

Validator Example

Create a custom validator that checks filename patterns:

import { defineProcessingPlugin, type FileError } from "nuxt-upload-kit"

interface FilenameValidatorOptions {
  pattern: RegExp
  message?: string
}

export const ValidatorFilename = defineProcessingPlugin<FilenameValidatorOptions>((options) => ({
  id: "validator-filename",
  hooks: {
    validate: async (file, context) => {
      if (!options.pattern.test(file.name)) {
        throw {
          message: options.message || `Filename must match pattern: ${options.pattern}`,
        } satisfies FileError
      }
      return file
    },
  },
}))

// Usage
const uploader = useUploadKit({
  plugins: [
    ValidatorFilename({
      pattern: /^[a-z0-9-]+\.[a-z]+$/i,
      message: "Filenames can only contain letters, numbers, and hyphens",
    }),
  ],
})

Processor Example

Create a processor that adds watermarks to images:

import { defineProcessingPlugin, type UploadFile } from "nuxt-upload-kit"

interface WatermarkOptions {
  text: string
  position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"
  opacity?: number
}

type WatermarkEvents = {
  applied: { file: UploadFile; text: string }
  skipped: { file: UploadFile; reason: string }
}

export const PluginWatermark = defineProcessingPlugin<WatermarkOptions, WatermarkEvents>((options) => ({
  id: "watermark",
  hooks: {
    process: async (file, context) => {
      // Skip non-images
      if (!file.mimeType.startsWith("image/")) {
        context.emit("skipped", { file, reason: "Not an image" })
        return file
      }

      // Skip remote files
      if (file.source !== "local") {
        context.emit("skipped", { file, reason: "Remote file" })
        return file
      }

      // Apply watermark
      const watermarkedBlob = await applyWatermark(file.data, options)

      context.emit("applied", { file, text: options.text })

      return {
        ...file,
        data: watermarkedBlob,
        size: watermarkedBlob.size,
      }
    },
  },
}))

async function applyWatermark(blob: Blob, options: WatermarkOptions): Promise<Blob> {
  // Implementation using Canvas API
  const img = await createImageBitmap(blob)
  const canvas = document.createElement("canvas")
  canvas.width = img.width
  canvas.height = img.height

  const ctx = canvas.getContext("2d")!
  ctx.drawImage(img, 0, 0)

  // Add watermark text
  ctx.font = "24px Arial"
  ctx.fillStyle = `rgba(255, 255, 255, ${options.opacity || 0.5})`
  ctx.fillText(options.text, 20, img.height - 20)

  return new Promise((resolve) => {
    canvas.toBlob((blob) => resolve(blob!), "image/jpeg", 0.9)
  })
}

Plugin Context

Every hook receives a context object with access to:

PropertyTypeDescription
filesUploadFile[]All files in the uploader
optionsUploadOptionsUploader configuration
storageStoragePlugin | undefinedStorage adapter (if configured)
emitFunctionEmit custom events (see below)

Accessing Storage

Plugins can upload derivatives (thumbnails, variants, etc.) using context.storage:

export const PluginThumbnailUploader = defineProcessingPlugin<{ upload: boolean }>((options) => ({
  id: "thumbnail-uploader",
  hooks: {
    process: async (file, context) => {
      // Check if storage is available
      if (!context.storage || !options.upload) {
        return file
      }

      // Upload thumbnail using storage adapter's standalone upload method
      if (file.preview?.startsWith("data:")) {
        const thumbnailBlob = dataUrlToBlob(file.preview)
        const thumbnailKey = `${file.id}_thumb.jpg`

        try {
          const result = await context.storage.upload(thumbnailBlob, thumbnailKey, {
            contentType: "image/jpeg",
          })

          // Store thumbnail metadata on file
          file.thumbnail = {
            url: result.url,
            storageKey: result.storageKey,
          }
        } catch (err) {
          console.warn(`Thumbnail upload failed:`, err)
          // Non-fatal - continue without thumbnail
        }
      }

      return file
    },
  },
}))

When to use context.storage:

  • ✅ Uploading generated derivatives (thumbnails, resized images, watermarked versions)
  • ✅ Creating multiple size variants of an image
  • ✅ Uploading processed/transformed versions alongside originals
  • ❌ Don't use for the main file upload (handled by the storage plugin's upload hook)

Important notes:

  • context.storage is undefined if no storage adapter is configured
  • Always check context.storage before using it
  • Use the standalone upload() method, not hooks.upload()
  • The storage adapter handles retries, authentication, and error handling internally

Emitting Events

Plugins can emit custom events that users can listen to:

defineProcessingPlugin((options) => ({
  id: "my-plugin",
  hooks: {
    process: async (file, context) => {
      context.emit("processing", { file })

      // Do work...

      context.emit("complete", { file, result: "success" })
      return file
    },
  },
}))

// Users listen with plugin ID prefix
uploader.on("my-plugin:processing", ({ file }) => {
  console.log("Processing:", file.name)
})

uploader.on("my-plugin:complete", ({ file, result }) => {
  console.log("Done:", file.name, result)
})

TypeScript: Typed Events

Define event types for full type safety:

type MyPluginEvents = {
  start: { file: UploadFile }
  progress: { file: UploadFile; percent: number }
  complete: { file: UploadFile; savedBytes: number }
  error: { file: UploadFile; error: Error }
}

export const MyPlugin = defineProcessingPlugin<MyOptions, MyPluginEvents>((options) => ({
  id: "my-plugin",
  hooks: {
    process: async (file, context) => {
      // context.emit is typed!
      context.emit("start", { file })
      context.emit("progress", { file, percent: 50 })
      context.emit("complete", { file, savedBytes: 1000 })
      return file
    },
  },
}))

Complete Example

import { defineProcessingPlugin, type UploadFile, type FileError } from "nuxt-upload-kit"

interface ImageResizerOptions {
  maxDimension: number
  quality?: number
}

type ImageResizerEvents = {
  resize: { file: UploadFile; from: { w: number; h: number }; to: { w: number; h: number } }
  skip: { file: UploadFile; reason: string }
}

/**
 * Resizes images to fit within a maximum dimension while preserving aspect ratio.
 *
 * @example
 * ```ts
 * const uploader = useUploadKit({
 *   plugins: [
 *     PluginImageResizer({ maxDimension: 800, quality: 0.9 })
 *   ]
 * })
 *
 * uploader.on('image-resizer:resize', ({ file, from, to }) => {
 *   console.log(`Resized ${file.name} from ${from.w}x${from.h} to ${to.w}x${to.h}`)
 * })
 * ```
 */
export const PluginImageResizer = defineProcessingPlugin<ImageResizerOptions, ImageResizerEvents>((options) => {
  const { maxDimension, quality = 0.85 } = options

  return {
    id: "image-resizer",
    hooks: {
      process: async (file, context) => {
        // Skip non-images
        if (!file.mimeType.startsWith("image/")) {
          return file
        }

        // Skip remote files
        if (file.source !== "local") {
          context.emit("skip", { file, reason: "Remote file" })
          return file
        }

        try {
          const img = await createImageBitmap(file.data)

          // Skip if already small enough
          if (img.width <= maxDimension && img.height <= maxDimension) {
            context.emit("skip", { file, reason: "Already within size limits" })
            return file
          }

          // Calculate new dimensions
          const ratio = Math.min(maxDimension / img.width, maxDimension / img.height)
          const newWidth = Math.round(img.width * ratio)
          const newHeight = Math.round(img.height * ratio)

          // Resize
          const canvas = document.createElement("canvas")
          canvas.width = newWidth
          canvas.height = newHeight

          const ctx = canvas.getContext("2d")!
          ctx.drawImage(img, 0, 0, newWidth, newHeight)

          const resizedBlob = await new Promise<Blob>((resolve) => {
            canvas.toBlob((blob) => resolve(blob!), file.mimeType, quality)
          })

          context.emit("resize", {
            file,
            from: { w: img.width, h: img.height },
            to: { w: newWidth, h: newHeight },
          })

          return {
            ...file,
            data: resizedBlob,
            size: resizedBlob.size,
            meta: {
              ...file.meta,
              resized: true,
              originalDimensions: { width: img.width, height: img.height },
            },
          }
        } catch (error) {
          console.warn(`[ImageResizer] Failed for ${file.name}:`, error)
          return file // Return original on failure
        }
      },
    },
  }
})

Best Practices

1. Always Check File Source

Remote files (source: "storage") have data: null. Always check before accessing file data:

process: async (file, context) => {
  if (file.source !== "local") {
    context.emit("skip", { file, reason: "Remote file" })
    return file
  }

  // Safe to access file.data
  const blob = file.data
}

2. Return Original on Failure

Never throw from process hooks unless you want to fail the upload. Return the original file to continue:

process: async (file, context) => {
  try {
    // Transform file...
    return transformedFile
  } catch (error) {
    console.warn(`[MyPlugin] Failed:`, error)
    return file // Continue with original
  }
}

3. Use Namespaced Plugin IDs

Prefix your plugin IDs to avoid collisions:

// Good
id: "mycompany-watermark"
id: "acme-validator-dimensions"

// Avoid
id: "watermark" // Too generic

4. Emit Events for Observability

Emit events so users can track plugin activity:

process: async (file, context) => {
  context.emit("start", { file })

  // Do work...

  context.emit("complete", { file, savings: originalSize - newSize })
  return file
}

5. Document Your Plugin

Include JSDoc comments with examples:

/**
 * Validates that images meet minimum dimension requirements.
 *
 * @example
 * ```ts
 * useUploadKit({
 *   plugins: [
 *     ValidatorMinDimensions({ minWidth: 800, minHeight: 600 })
 *   ]
 * })
 * ```
 */
export const ValidatorMinDimensions = defineProcessingPlugin<Options>((options) => ({
  // ...
}))

6. Handle All Image Types Appropriately

Some formats need special handling:

process: async (file, context) => {
  // Skip non-processable formats
  if (file.mimeType === "image/gif" || file.mimeType === "image/svg+xml") {
    context.emit("skip", { file, reason: "Format not supported" })
    return file
  }

  // Process other images...
}
Copyright © 2026