nuxt-media-library

Nuxt module for media library management — chunked uploads, asset previews, and resource policies
683
Install
npm install nuxt-media-library
Latest Version:0.0.63
Vue:3
License:MIT
Last Updated:Oct 22, 2026
Links: npm
Maintainer: fritill

Media Library

npm version npm downloads License Nuxt

Media library components and upload utilities for Nuxt, Vue, and custom elements.

Features

  • ⛰  Foo
  • 🚠  Bar
  • 🌲  Baz

Quick Setup

Install the module to your Nuxt application with one command:

npx nuxi module add media-library

That's it! You can now use Media Library in your Nuxt app ✨

Usage

Nuxt installation (Vue components only)

export default defineNuxtConfig({
  modules: ['media-library'],
  mediaLibrary: {
    // Auto-register Vue components from src/runtime/components
    components: true,
    // Where the Nuxt server reaches the media library (with /v1). Server-side
    // only, never sent to browsers; prefer NUXT_MEDIA_LIBRARY_BASE_URL.
    baseURL: 'http://media-library.internal/v1',
    // Only for direct browser uploads (proxyUploads: false): a public address.
    // publicBaseURL: 'https://media.example.com/v1',
    // false when your own server/api/media-library/[...].ts serves /manage
    // (below). Default true: the module's /manage proxies.
    // manageProxy: false,
    // Uploads always go through Nuxt server (/api/media-library/uploads/*)
    routes: {
      createSession: '/uploads/sessions',
      getSession: '/uploads/sessions/:id',
      uploadPart: '/uploads/sessions/:id/parts/:part',
      finalize: '/uploads/sessions/:id/finalize',
      abort: '/uploads/sessions/:id/abort',
    },
    partSize: 8 * 1024 * 1024,
    concurrency: 3,
    checksum: { algorithm: 'SHA-256', perPart: true, final: true },
    // Optional. Sent to browsers too, except credentials: Authorization,
    // Cookie, Proxy-Authorization and X-Api-Key stay server-side (only the
    // /manage proxies add them, never /delivery). X-Tenant is not needed
    // (the media library ignores it); it is still sent when set.
    // headers: { 'X-Tenant': 'acme' },
  },
})

Nuxt puts the public runtime config in every page, so the module never copies baseURL or credential headers there. Set the media library's internal address only server-side (NUXT_MEDIA_LIBRARY_BASE_URL). The Nuxt server issues no media grants and needs no service token: the MLMedia widgets read through each service's resolve backend (below).

Nuxt usage

<template>
  <MediaLibrary />
</template>

If you use the uploader component in Nuxt, provide config via the Nuxt plugin (see your module/plugin setup) and use:

<template>
  <MlFileUpload />
</template>

Reading files through your services (resolve backends)

The MLMedia widgets (MLMediaGallery, MLMediaField, the file picker, the editor) and the upload queue read files through the service that owns them. The service decides who may see what; the Nuxt server holds no media credential and issues no grants.

// nuxt.config.ts (or the service's layer)
export default defineNuxtConfig({
  mediaLibrary: {
    backends: {
      videos: {
        upload: { start: '/api/videos/uploads' },
        resolve: '/api/videos/media/resolve',
      },
    },
  },
})
<MLMediaGallery v-model="lesson.mediaIds" backend="videos" />
# the videos service (pkg_media[fastapi])
gate = AccessGate(client, can_access=lambda user, ids: videos.visible_media(user, ids))
app.include_router(create_media_router(gate, get_current_user, prefix="/videos/media"))
  • Request: POST {resolve} from the browser, same origin, with the user's session (cookies), body { "assetIds": ["…"] }. At most 100 ids per request; the widgets split longer lists.
  • Response: { "items": [{ "assetId", "status", "asset", "error" }] }, the shape of the media library's /manage/assets/resolve. status is ok, not_found, forbidden (the service's can_access refused) or error; asset is set when ok. HLS/DASH links are reloaded before streamUrlExpiresIn runs out, as before.
  • backend picks the route: the field, the gallery (also when it places picked files by kind), the picker's already-attached files, the editor (useMLMediaEditor().open(id, { backend })), useMLMediaAssets(ids, { backend }) and the upload queue's status polling all read there.
  • Without backend, or for a backend without resolve, they read through Nuxt's own /api/media-library/manage/assets/resolve as before. An unknown name shows the files as errors with a message naming it.
  • Still through Nuxt's own /manage proxy: the picker's library list and its preview of listed files, and the processing stream.

Serving /manage yourself

The module's server proxies forward /api/media-library/manage/* to the media library with the browser's credentials and your static headers. To add your own per-request credential, turn off the built-in /manage proxies (their routes are more specific than a catch-all file and would answer first; the module warns at startup when it sees a shadowed catch-all) and serve them from one file:

// nuxt.config.ts
export default defineNuxtConfig({
  mediaLibrary: { manageProxy: false },
})
// server/api/media-library/[...].ts
import { createMLCatchAllProxy } from 'nuxt-media-library/server'

export default createMLCatchAllProxy({
  requestHeaders: async event => ({ Authorization: `Bearer ${await getAccessToken(event)}` }),
})
  • Grant mode is gone: createMLCatchAllProxy and createMLProxy no longer take authorize or issueGrant, and the private mediaLibrary.serviceToken config is not read. Passing either option throws, so an app that relied on them fails loudly instead of forwarding unchecked. Read files through resolve backends instead.
  • /delivery never gets a credential from any source: not the static credentials (a service token in headers), not the browser's own Authorization, Cookie, Proxy-Authorization or X-Api-Key (a signed-in user's token, which the media library refuses), and not one a requestHeaders factory adds. Grants travel in X-Media-Grants; other non-credential headers such as X-Tenant pass. requestHeaders still sees the request's cookies, and /manage is unchanged.
  • With manageProxy: false the module still serves /api/media-library/delivery/** and, when serviceUploads.proxy is on, the service-upload pass-through. Your catch-all handles everything else under /api/media-library.

Showing media on public pages (embedded media)

Public pages never resolve an asset id. Each service puts the media into its own response (Python: pkg_media's MediaEmbedder, or StrawberryMedia for GraphQL), having decided who may see what. The page renders the fields it gets:

interface EmbeddedMedia {          // from the service's GraphQL schema (codegen)
  assetId: string
  kind: 'image' | 'video' | 'audio' | 'document' | 'other'
  url: string | null               // the file; null when denied or not ready
  poster: string | null            // the one image to show; set even when denied
  sources: { url: string, width: number, height?: number | null }[]  // poster sizes
  streams: { hls?: string | null, dash?: string | null } | null
  width?: number | null; height?: number | null; alt?: string | null
  expiresIn: number | null         // seconds until the first link expires; null = never
  public: boolean                  // every link tokenless (cache-safe)
}

The module auto-imports the helpers that read it (a structural type, MLEmbeddedMedia, so any codegen type fits):

Helper Gives
mlMediaPoster(media, fallback?) the image to show, else fallback
mlMediaSrcset(media) srcset from media.sources, or undefined
mlPlayableSource(media, { preferFile? }) { src, type: 'hls' | 'progressive' }: the HLS ladder, else the file; null when locked or not ready
mlIsPlayableVideo(media) a video the viewer can play now
useMLMediaRenewal(media, renew, { marginSeconds?, retrySeconds? }) calls renew 5 minutes (default) before tokened links expire, and again every 30 s (default) while it fails; browser only
<script setup lang="ts">
const { data: item } = await useAsyncData('article', fetchArticle)
// Re-fetch before the links expire and swap in only `media`.
useMLMediaRenewal(() => item.value?.media, async () => {
  const fresh = await fetchArticle()
  if (item.value && fresh?.media) item.value = { ...item.value, media: fresh.media }
})
const source = computed(() => mlPlayableSource(item.value?.media))
</script>

<template>
  <!-- `sizes` is the box's widest rendered width. -->
  <img
    :src="mlMediaPoster(item?.media, '/placeholder.jpg')"
    :srcset="mlMediaSrcset(item?.media)"
    sizes="(min-width: 1024px) 420px, 100vw"
    alt=""
    loading="lazy"
  >
  <!-- A video: the app's own player (hls.js for type 'hls'). -->
  <AppVideoPlayer v-if="source" :src="source.src" :type="source.type" :poster="item?.media?.poster" />
</template>
  • Locked or missing: url and streams are null; show poster (and a paywall). No call to the media library can do better.
  • Renewal: tokened links expire (expiresIn). useMLMediaRenewal fetches the record again before then; swap in only the new media and resume the player at its playhead (key the player on its source).
  • SEO: pass media.poster (and the service's embedded share image) to your SEO helper as URLs; the HTML rendered on the server then carries them.
  • Caching: a response with a tokened link is per user (public: false); don't cache it at the edge.
  • useMLAssetPreview, <MLPreviewProvider> and v-asset resolve ids in the browser through /delivery. They are deprecated (a warning in dev) and go with /delivery; so do read grants (below).

Design and rulings: docs/design/embedded-media.md. Moving a service and its layer over, step by step: docs/design/embedded-media-migration-guide.md.

Showing assets of several services: read grants

Deprecated with /delivery: public pages show embedded media instead (see above), and no grant is needed.

Every link comes from the media library; each service decides only for its own assets by returning a read grant (mlg_…, e.g. the Python client's client.grants.read(ids)) with the data that references them. Hand the grants to the module and every preview resolve sends them in one request:

const { addMediaGrants } = useMLMediaGrants()

const lesson = await $fetch('/api/lessons/42')        // course service
const profile = await $fetch('/api/profile')          // profile service
addMediaGrants(lesson.mediaGrant, profile.mediaGrant) // "mlg_…" or { grant, expiresAt }
  • Live grants (up to 10, latest expiry first) go in X-Media-Grants on POST /api/media-library/delivery/assets/resolve; the proxy forwards them. Expired ones are dropped.
  • Assets no grant lists come back forbidden from the server and are shown as broken (isBroken(id)), as before; they are resolved again whenever grants are added.
  • Grants live in the browser only, never in server-side state.

Uploads started by your backend services

When a backend service starts the upload (the Python client's create_upload_router), the service creates the session and returns a grant for it, and useMLServiceUpload sends the parts. Where the parts go is the app's decision, not the service's:

// nuxt.config.ts
export default defineNuxtConfig({
  mediaLibrary: {
    // server side only: set NUXT_MEDIA_LIBRARY_BASE_URL (e.g. http://media-library.internal/v1)
    serviceUploads: { proxy: true },             // parts go through /api/media-library/service-uploads
    // or, direct to the media library (needs CORS for the site):
    // serviceUploads: { baseURL: 'https://media.example.com/v1' },
  },
})
const { upload, progress } = useMLServiceUpload()
const { sessionId, assetId } = await upload(file, {
  start: '/api/videos/uploads',
  body: { profile: 'lesson-video', context: { lessonId } },
})
await $fetch(`/api/videos/lessons/${lessonId}/video`, { method: 'PUT', body: { sessionId, assetId } })
  • The upload base is, in order: upload(file, { baseURL }), useMLServiceUpload({ baseURL }), serviceUploads.baseURL (NUXT_PUBLIC_MEDIA_LIBRARY_SERVICE_UPLOAD_BASE_URL), the pass-through route when serviceUploads.proxy is on, and last the uploadBaseUrl an older service returns. With none, upload fails before any part is sent.
  • The pass-through route (serviceUploads.proxy: true) forwards only a session's GET, parts PUT, finalize and abort to the private mediaLibrary.baseURL. It requires the browser's Bearer mlg_… grant and forwards it and X-Tenant untouched (the static X-Tenant when the browser sends none, and only if one is configured). It never adds static auth headers, drops cookies, and streams request bodies. Mount it yourself with createMLServiceUploadsProxy() from nuxt-media-library/server for another path.
One layer per service, widgets by backend name

Each service can ship a Nuxt layer that registers its upload backend and serves its own routes (start, refresh, attach, data) to its service. Nuxt merges the layers, so every widget on the site can use any backend by name.

// itq-videos-layer/nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-media-library'],
  mediaLibrary: {
    backends: {
      videos: {
        upload: { start: '/api/videos/uploads' }, // refresh defaults to `${start}/:sessionId/grant`
        claim: '/api/videos/uploads/:sessionId/attach', // pkg_media on_attach
        resolve: '/api/videos/media/resolve', // pkg_media create_media_router
      },
    },
  },
})
// the site
export default defineNuxtConfig({
  extends: ['itq-videos-layer', 'itq-courses-layer'],
  modules: ['nuxt-media-library'],
  mediaLibrary: { serviceUploads: { proxy: true } },   // where parts go, for every backend
})
<MLMediaGallery
  v-model="lesson.mediaIds"
  backend="videos"
  profile="lesson-video"
  :service-body="{ context: { lessonId } }"
  @uploaded="(assetId, role, sessionId) => attach(assetId, sessionId)"
/>
  • backend on MLMediaGallery, MLMediaField and the file picker (and useMLMediaUpload): a name in mediaLibrary.backends. service is its older name and still takes { start } for a one-off; backend wins. Without either, widgets upload through Nuxt's own route as before; an unknown name fails that upload with a message naming the missing backend.
  • Each backend has a kind: media-library (the default) or media-kit. This version uploads only to media-library backends; a media-kit one fails the upload with a clear message.
  • With claim, the upload queue attaches files whose page was left before the widget attached them (POST { assetId, profile }; 409 counts as attached). The tray shows a refusal with "Add again".
  • mediaLibrary.serviceUploads.backends still works: each entry becomes a media-library backend with that upload and no claim.
  • The start request carries the file (mimeType, sizeBytes, filename), profile (each role's, in a gallery with roles) and service-body. The service's profile decides kind, visibility and processing, so visibility, processing-config and collection-name are not used with service.
  • uploaded also gives the upload's sessionId (gallery: (assetId, role, sessionId), field: (assetId, sessionId)), including files uploaded from the picker and chosen. Send both to the service's attach endpoint: its claim_upload needs them.
  • Processing progress uses the processing stream and, when it is not available, polls the backend's resolve route (Nuxt's own route without one), so the service's can_access must let the user see a file they just uploaded.
  • There is no default backend: each layer's widgets name theirs. The module only stores the paths; each layer's own routes reach its service.

File meta from the widgets

MLMediaField and MLMediaGallery report what they know about their files: name, extension, MIME type, kind, size, duration, width, height and pages.

<MLMediaField v-model="lesson.videoId" v-model:meta="videoMeta" @meta="m => console.log(m?.durationSeconds)" />

<MLMediaGallery v-model="post.mediaIds" v-model:meta="mediaMeta" @meta="(assetId, m) => …" />
  • v-model:meta is output only: MediaFileMeta | null on the field, a map by asset ID on the gallery (every role). @meta fires per file that changed, with null when it left.
  • A file uploaded in this tab is read in the browser right away (source: 'local', duration and size from a hidden video, audio or image element). The media library's data replaces it once loaded (source: 'server'); values it has no rendition for yet stay local.
  • A file needs an asset ID before it has meta: during the byte upload it has none yet.
  • useMLMediaMeta(ids, getAsset) gives the same map for your own lists.

Permalinks

A permalink is a stable public address {media host}/p/{name} for a public asset (server docs/api/permalinks.md). Store the name, e.g. users/42/avatar, and re-point it when the file changes:

const { set, get, remove } = useMLPermalinks()
const link = await set('users/42/avatar', assetId) // { name, assetId, url, createdAt, updatedAt }
await get('users/42/avatar') // null when unknown
  • The media library takes a service token only. The module's /api/media-library/manage/permalinks/{name} proxy works when mediaLibrary.headers carries one. Otherwise call PUT/GET/DELETE {baseURL}/manage/permalinks/{name} from your server (permalinkPath(name) from /server).
  • isValidPermalinkName checks a name the way the server does; permalinkUrl(mediaHost, name) builds the public address.

Error messages, in the editor's language

When a backend refuses a request it answers { detail, reason }: reason is a stable code (py_client UploadRejected: too_large, mime_type, context_mismatch, …), detail English text. The widgets show, first that applies:

  1. the text for a known reason, in the editor's language;
  2. detail (a 422's first msg);
  3. the backend's inner message, then the status text;
  4. the place's own fallback ("Upload failed", "Could not add the file", …).

The body is read whether the module's own proxy streamed it (err.data) or an app proxy rethrew it with h3 createError({ data }) (err.data.data). The raw "[POST] url: 400" text is never shown.

The language is the app's @nuxtjs/i18n locale, else mediaLibrary.locale; its language part picks the texts (ar-SA → ar). Built in: en and ar. An app can change any text, or add a language:

mediaLibrary: {
  locale: 'ar',                      // without @nuxtjs/i18n
  errorTexts: {
    ar: {
      reasons: { too_large: 'حجم هذا الملف أكبر من المسموح.' },
      fallbacks: { upload: 'فشل رفع الملف' },
    },
  },
},

A reason with no text in the editor's language uses the English one, then detail. mediaErrorMessage(err, fallback, { locale, texts }) and mlErrorMessage(err, fallback) are exported for your own pages.

Vue installation (non-Nuxt)

npm i media-library
import { createApp } from 'vue'
import MediaLibraryPlugin from 'media-library/vue'

const app = createApp(App)
app.use(MediaLibraryPlugin, {
  chunkUploader: {
    // Use a same-origin proxy (recommended) or set baseURL for direct calls
    baseURL: '',
    routes: {
      createSession: '/api/media-library/uploads/sessions',
      getSession: '/api/media-library/uploads/sessions/:id',
      uploadPart: '/api/media-library/uploads/sessions/:id/parts/:part',
      finalize: '/api/media-library/uploads/sessions/:id/finalize',
    },
    partSize: 8 * 1024 * 1024,
    concurrency: 3,
    checksum: { algorithm: 'SHA-256', perPart: true, final: true },
    headers: {},
  },
})
<template>
  <MediaLibrary />
  <MlFileUpload />
</template>

Custom element via CDN

Load the prebuilt custom element bundle (IIFE):

<script src="/dist-ce/media-library.custom-element.js"></script>
<media-library></media-library>
<ml-file-upload></ml-file-upload>

Global config:

<script>
  window.MediaLibraryConfig = {
    chunkUploader: {
      baseURL: '',
      routes: {
        createSession: '/api/media-library/uploads/sessions',
        getSession: '/api/media-library/uploads/sessions/:id',
        uploadPart: '/api/media-library/uploads/sessions/:id/parts/:part',
        finalize: '/api/media-library/uploads/sessions/:id/finalize',
      },
      partSize: 8388608,
      concurrency: 3,
      checksum: { algorithm: 'SHA-256', perPart: true, final: true },
      headers: {},
    }
  }
</script>

Per element:

<ml-file-upload
  chunk-uploader='{"baseURL":"","routes":{"createSession":"/api/media-library/uploads/sessions","getSession":"/api/media-library/uploads/sessions/:id","uploadPart":"/api/media-library/uploads/sessions/:id/parts/:part","finalize":"/api/media-library/uploads/sessions/:id/finalize"},"partSize":8388608,"concurrency":3,"checksum":{"algorithm":"SHA-256","perPart":true,"final":true},"headers":{}}'
></ml-file-upload>

Developer Guide

Nuxt development (module)

# Install dependencies
npm install

# Generate type stubs and prepare the playground
npm run dev:prepare

# Run the playground (Nuxt module usage)
npm run dev

Use the playground to test module options and auto-registered components.

SSR notes (Nuxt)

If a component uses browser-only APIs, use one of these patterns:

if (process.client) {
  // window/document usage
}
import { onMounted } from 'vue'

onMounted(() => {
  // safe to access window/document here
})
<ClientOnly>
  <MediaLibrary />
</ClientOnly>

Or make it client-only by naming the file MyComponent.client.vue.

Vue development (library)

# Build the Vue entry and type declarations
npm run prepack

Import from media-library/vue in a Vue app to verify component behavior.

Custom element development (CE)

# Build the standalone custom element bundle
npm run build:custom-element

Load dist-ce/media-library.custom-element.js in any HTML page and use <media-library></media-library> to verify the output.

Tests

npm run lint
npm run test
npm run test:watch
npm run test:types

Version History

Version Released Vue License
0.0.63 ^3.0 MIT
0.0.62 ^3.0 MIT
0.0.61 ^3.0 MIT
0.0.60 ^3.0 MIT
0.0.59 ^3.0 MIT