Docs

Images

@nifrajs/image gives you a CLS-safe, responsive <Image> with lazy-by-default loading and a pluggable loader. The core bundles no image codec — point the loader at a CDN and the runtime stays tiny. Or self-host: the optional @nifrajs/image/server resizes your own images with Bun.Image.

The <Image> component

width and height are required and validated > 0 — they reserve layout space so the page never shifts when the image loads (the CLS contract). It's loading="lazy" + decoding="async" by default; mark the LCP image with priority to get eager + fetchpriority="high". Extra DOM props (className, style, id, data-*) pass straight through to the <img>.

TS
import { Image } from "@nifrajs/web-react/image"
import { cloudflareLoader } from "@nifrajs/image"

const cdn = cloudflareLoader()   // → /cdn-cgi/image/format=auto,width=W/...

export default function Page() {
  return <>
    {/* The LCP image: priority → loading="eager" + fetchpriority="high"
        (React 19 also emits a <link rel="preload" as="image"> for it). */}
    <Image src="/hero.jpg" width={1200} height={630} alt="Hero banner"
           priority loader={cdn} sizes="(max-width: 1200px) 100vw, 1200px" />

    {/* Below the fold: lazy + async-decode by default. width/height
        reserve the box, so nothing shifts when it loads (no CLS). */}
    <Image src="/thumb.jpg" width={400} height={300} alt="Thumbnail"
           loader={cdn} quality={75} />
  </>
}

The responsive srcSet is built from widths (default [width, width*2] for 1×/2× retina), de-duped and sorted; if every width yields the same URL (e.g. the identity loader), srcSet is omitted. The browser then picks the right candidate for the device's pixel ratio and the sizes you declare.

Loaders

A loader is a pure ({ src, width, quality? }) => string URL builder. Ship with cloudflareLoader() (Cloudflare Images) or the no-op identityLoader, or write your own for any CDN — Imgix, Cloudinary, a signed-URL service, anything.

TS
import type { ImageLoader } from "@nifrajs/image"
import { cloudflareLoader, identityLoader } from "@nifrajs/image"

// Built-in: Cloudflare Images. `base` prepends an origin to bare paths.
const cf = cloudflareLoader({ base: "https://assets.example.com" })

// The default when you pass no loader: no transform (still CLS-safe + lazy,
// just no responsive variants).
identityLoader({ src: "/a.png", width: 800 })   // → "/a.png"

// Any CDN is a pure (src, width, quality?) → URL builder:
const imgix: ImageLoader = ({ src, width, quality }) =>
  `https://my.imgix.net${src}?w=${width}&auto=format${quality ? `&q=${quality}` : ""}`

Self-hosting — nifra's own resize endpoint

No CDN? @nifrajs/image/server's createImageHandler is a self-hosted resize endpoint backed by Bun.Image (libjpeg-turbo / libspng / libwebp, decoded off-thread). Pair it with selfHostedLoader and nifra resizes your own images — no third party in the path.

TS
// 1. The loader (browser-safe) points <Image> at your endpoint:
import { selfHostedLoader } from "@nifrajs/image"
const resize = selfHostedLoader({ endpoint: "/_image" })
// <Image src="/photo.png" width={800} height={450} alt="…" loader={resize} />
//   → /_image?src=%2Fphoto.png&w=800  (+ a 1600w retina candidate)

// 2. The endpoint (server-only) does the actual resize with Bun.Image:
import { createImageHandler } from "@nifrajs/image/server"

const image = createImageHandler({
  root: "./public",                          // local sources resolve under here (traversal+symlink guarded)
  allowedOrigins: ["https://cdn.example"],   // remote sources: allowlist only (omit ⇒ none)
  // maxWidth, maxSourceBytes, maxSourcePixels, concurrency, cacheMaxAge — all tunable
})

// mount it in your router:
app.get("/_image", (c) => image(c.req))

The handler is hardened by default, because src/w/q are untrusted input:

  • SSRF, fail-closed. Local sources are confined to root with path-traversal and symlink containment checks; remote sources are refused unless their exact origin is in allowedOrigins (omit it ⇒ no remote fetch at all). Only http(s) URLs are considered, redirects are refused, and fetches are byte-capped + timed out.
  • DoS guards. Strict integer parsing (no Number() coercion), width clamped to maxWidth, a source byte cap, a decompression-bomb pixel cap (via a cheap header-only probe), and a concurrency semaphore bounding the CPU-heavy codec work.
  • Correct + cacheable. Never upscales past the intrinsic width; negotiates WebP via Accept (with Vary: Accept); serves Cache-Control: …, immutable + a strong ETag computed before any decode, so a conditional If-None-Match short-circuits the whole pipeline.

The handler reads the filesystem, so its local-source path targets Node/Bun servers— but the codec itself is a pluggable ImageBackend, so it runs anywhere (see below).

Backends — Bun, sharp, or WASM (edge)

The codec is a seam: the handler owns all the security above; a backend just decodes/resizes/encodes. Three official backends, all from @nifrajs/image/backends:

  • bunImageBackend() — the default; Bun.Image on Bun servers.
  • sharpImageBackend(sharp) — libvips for Node. You pass your sharp import (nifra keeps zero dependency on it, and you pin the version).
  • wasmImageBackend(codecs) — pure-WASM decode/resize/encode you wire up (jSquash is the common choice). The only backend that runs on the edge (Workers / Vercel-Edge / Deno-Deploy), where there's no native codec. @nifrajs/image/backends has no node: imports, so it bundles for the edge cleanly; the bomb-safe header probe is built in (it never decodes just to read dimensions).
TS
import { createImageHandler } from "@nifrajs/image/server"
import { sharpImageBackend, wasmImageBackend } from "@nifrajs/image/backends"

// Node — libvips via sharp. Pass your own import (nifra never depends on it):
import sharp from "sharp"
createImageHandler({ backend: sharpImageBackend(sharp), root: "./public" })

// Edge (Workers / Vercel-Edge / Deno-Deploy) — pure-WASM codecs, e.g. jSquash:
import decodeJpeg from "@jsquash/jpeg/decode"
import resize from "@jsquash/resize"
import encodeWebp from "@jsquash/webp/encode"   // + @jsquash/png, @jsquash/jpeg encoders
createImageHandler({
  allowedOrigins: ["https://cdn.example"],
  backend: wasmImageBackend({
    decode: decodeJpeg,                                       // → { data, width, height } (RGBA)
    resize: (img, width, height) => resize(img, { width, height }),
    encode: async (img, format) => new Uint8Array(await encodeWebp(img)), // switch by format
  }),
})

Prefer not to run a codec on the edge at all? cloudflareLoader still resizes at the CDN — same <Image>, swap the loader.

Signed URLs

Because src/w/q are attacker-controllable, a public resize endpoint can be resize-bombed — enumerating widths/qualities to flood CPU + cache. Signing shuts that down: set a secret on the loader and the handler, and the endpoint rejects any URL it didn't mint (forged, tampered, or expired) with 403, before any fetch or decode. The signature is a portable synchronous HMAC-SHA256, so the (sync) loader can sign inline — even on the edge.

TS
// Lock the endpoint to URLs YOU minted — kills resize-bombing (w/q enumeration).
// SAME secret on loader + handler; it's server-only (inject from env, like a session secret).
import { selfHostedLoader, signImageUrl } from "@nifrajs/image"
import { createImageHandler } from "@nifrajs/image/server"

const resize = selfHostedLoader({ endpoint: "/_image", secret: env.IMAGE_SECRET })
//  → /_image?src=…&w=800&s=<hmac>   (stable: an SSR-signed URL hydrates + caches identically)

const image = createImageHandler({ root: "./public", signing: { secret: env.IMAGE_SECRET } })
//  any request without a valid &s= (forged / tampered / expired) → 403, before any fetch or decode

// Time-limited links to private images (server-side only):
const url = signImageUrl("/_image", { src: "/private/a.jpg", width: 800 }, {
  secret: env.IMAGE_SECRET,
  expiresIn: 300, // seconds → adds &exp=
})

Signatures over (src, w, q) are stable (no expiry), so an SSR-rendered <Image> srcset hydrates and caches identically. The secret makes a loader config server-only — inject it from env and never import it into a route/client module (same discipline as a session secret). For time-limited links to private images, signImageUrl(…, { expiresIn }) adds an &exp= the handler enforces.

Reading intrinsic dimensions

Don't want to hardcode width/height? Read them from the file header in pure JS — no decode, no native dependency — and bake them into a build-time manifest. Supports PNG, JPEG (scans SOFn, skipping earlier segments), GIF, and WebP (VP8 / VP8L / VP8X).

TS
import { imageDimensions, readImageDimensions } from "@nifrajs/image"

// Pure-JS header read — PNG / JPEG / GIF / WebP. No decode, no codec, no deps.
const info = await readImageDimensions(Bun.file("public/hero.jpg"))
// → { width: 1200, height: 630, format: "jpeg" }   (null if unrecognized)

// Build-time tooling: pre-read sizes into a manifest so <Image> is CLS-safe
// without hardcoding width/height at every call site.
imageDimensions(new Uint8Array(headerBytes))

Notes

  • The loader is the seam: on the edge, resizing belongs at the CDN (which caches variants and negotiates format=auto); on a Node/Bun server you can self-host it with @nifrajs/image/server instead. Same <Image>, swap the loader.
  • Always set sizes for images that aren't a fixed pixel width, so the browser selects the smallest sufficient srcSet candidate.
  • <Image> ships for all five adapters (React, Preact, Vue, Solid, Svelte) — import from @nifrajs/web-<framework>/image. Each builds the same <img> from the agnostic resolveImage (non-React adapters map to lowercase HTML attrs via toHtmlAttrs).
Proudly built with Nifra — server-rendered on Cloudflare Pages.MIT