Skip to content

Self-Hosted On-Demand Server

No CDN, don't want to pre-run the CLI, want images resized per-request in production too? @macrulez/vue-image-kit/server exports the same handler the Vite plugin's dev middleware uses — a small, framework-agnostic Node request handler you mount yourself. The Nuxt module can also register it as a real Nitro route automatically.

ts
import { createImageHandler } from '@macrulez/vue-image-kit/server'

const handler = createImageHandler({ root: './public' })

Plain Node http:

ts
import { createServer } from 'node:http'
import { createImageHandler } from '@macrulez/vue-image-kit/server'

const imageHandler = createImageHandler({ root: './public' })

createServer((req, res) => {
  if (req.url?.startsWith('/_vik/image')) {
    imageHandler(req, res)
    return
  }
  // ...serve everything else
}).listen(3000)

Express:

ts
app.get('/_vik/image', createImageHandler({ root: './public' }))

Request shape: GET {route}?src=/photos/cat.jpg&w=800&format=webp&q=80src is required (resolved strictly under root; anything that escapes it is rejected with 403, a nonexistent file with 404). w, format (jpg/webp/avif/png) and q are all optional. With neither w nor format, the original bytes are streamed through untouched — no sharp call, works for any file type. Otherwise the result is resized/re-encoded with sharp and cached to disk (cacheDir, default <root>/.vik-cache) keyed by every param that affects the output, so a repeat request is a cache hit, not a re-encode.

ts
buildImageUrl('/photos/cat.jpg', { width: 800, format: 'webp' })
// → '/_vik/image?src=%2Fphotos%2Fcat.jpg&w=800&format=webp'

Options

root

string, required. Directory src is resolved (and confined) to.

cacheDir

string · default: <root>/.vik-cache. Where transformed output is cached.

maxAge

number · default: 31536000 (1 year). Cache-Control: public, max-age=..., must-revalidate, plus a source-derived ETag. A cached response is still reused with zero request for the full maxAge — that's what max-age means, regardless of this header — this only affects what happens after it expires (or on an explicit revalidation, e.g. a hard refresh): a cheap ETag-backed 304 instead of a full re-download, and (unlike immutable) the browser is at least allowed to ask. If a source can change and that needs to be picked up sooner than a year, lower maxAge, use no-cache, or put a version in the URL — this option alone won't make that happen.

allowedWidths

number[], optional. Restrict w to exactly these values (400 on anything else). Unset: any positive integer, clamped to maxWidth.

maxWidth

number · default: 4000. Upper bound for w when allowedWidths isn't set.

Scope: this handles one transform per request for standard raster sources (jpg/png/webp/avif → jpg/webp/avif/png) — the realistic "give me this photo at width X" case. It does not replicate the CLI's GIF/SVG special-casing or multi-variant batch generation (src/cli/processor.ts is the place for that) — a .gif/.svg source always passes through untouched, byte-identical, regardless of w/format (sharp is never asked to resize a GIF here — that would silently drop its animation — or rasterize an SVG). Any other unrecognized source extension falls back to jpg when a transform is requested, or passes through untouched when neither w nor format is set.

Security note: error responses include the underlying error message as plain text (e.g. "sharp is not installed") to make self-hosted setups easier to debug. If you don't want that detail reaching clients, put this behind your own error-handling middleware in production.

Wiring VImage to it: loader="server"

VImage's loader prop builds request URLs against the handler automatically — no manual buildImageUrl() calls:

vue
<VImage src="/photos/cat.jpg" alt="Photo" :widths="[400, 800]" loader="server" />

Same shape as cdn: combined with widths, each candidate gets its own request URL instead of one shared URL. The route defaults to /_vik/image (matching the Vite dev middleware and the handler's own convention) — override it per-component with loaderRoute, or set it once for every VImage via the Vue plugin (app.use(VImageKitPlugin, { serverRoute: '/api/img' })) or the Nuxt module's onDemandServer option (see Nuxt module, which also registers the actual Nitro route for you). If both cdn and loader="server" are set on the same image, cdn wins — an external CDN already resolves the image, the local on-demand server is the fallback for when there isn't one.

Resolving the route programmatically: useServerRoute()

VImage and buildImageUrl() need to agree on which route the handler is actually mounted at. useServerRoute(localOverride?) is the resolution logic behind loader="server"/loaderRoute — exposed for custom loaders or headless setups that need the same route without going through VImage:

ts
import { useServerRoute } from '@macrulez/vue-image-kit'

const route = useServerRoute() // '/_vik/image' unless overridden

Resolution order:

  1. localOverride argument, if passed (matches loaderRoute on VImage).
  2. The value provided via app.provide(SERVER_ROUTE_KEY, ...) — set automatically by VImageKitPlugin's serverRoute option and by the Nuxt module's onDemandServer.route.
  3. /_vik/image — the handler's own default, also exported as DEFAULT_SERVER_ROUTE.