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.
import { createImageHandler } from '@macrulez/vue-image-kit/server'
const handler = createImageHandler({ root: './public' })Plain Node http:
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:
app.get('/_vik/image', createImageHandler({ root: './public' }))Request shape: GET {route}?src=/photos/cat.jpg&w=800&format=webp&q=80 — src 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.
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:
<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:
import { useServerRoute } from '@macrulez/vue-image-kit'
const route = useServerRoute() // '/_vik/image' unless overriddenResolution order:
localOverrideargument, if passed (matchesloaderRouteonVImage).- The value provided via
app.provide(SERVER_ROUTE_KEY, ...)— set automatically byVImageKitPlugin'sserverRouteoption and by the Nuxt module'sonDemandServer.route. /_vik/image— the handler's own default, also exported asDEFAULT_SERVER_ROUTE.