@blurwind/next

Next.js <Image> adapter for blurwind — instant blur placeholders with a fade-in.


Keywords
blur, placeholder, lqip, next, nextjs, next-image, react
License
MIT
Install
npm install @blurwind/next@0.4.0

Documentation

blurwind

Build-time blur placeholders for the web — framework-agnostic, zero-runtime, fail-safe.

npm license


Why

A large image that pops in after a blank box is one of the most visible ways a page feels slow. The fix is a LQIP (Low-Quality Image Placeholder): a tiny, blurred stand-in shown instantly while the real image downloads, then faded out.

blurwind generates those placeholders at build time and inlines them as ~200-byte data: URIs, so:

  • LCP improves — meaningful pixels paint immediately, no round-trip.
  • CLS stays at zero — the layout box is reserved up front.
  • Perceived load feels instant — the white flash is gone.

There are great tools for this already (plaiceholder, next/image's own blur). What blurwind adds is a framework-agnostic core with a pluggable strategy system and a byte-stable, zero-runtime manifest you can import anywhere — plus a generator that never breaks your build when a CDN hiccups.

The approach — and why not BlurHash?

blurwind's technique isn't the first thing we tried. It's what survived after shipping 67+ CDN images on a real product and hitting a wall with every "clever" alternative. The short version of that journey:

The problem. Images lazy-load from a CDN, so users stared at empty boxes until each one arrived. We wanted something nice to show instantly in the meantime.

Attempt 1 — large Base64 placeholders. Generated with plaiceholder at build time, ~400 bytes each. It worked — the blur showed immediately and positioning was correct — but 67 × ~400 B ≈ 32 KB of payload spent purely on placeholders.

Attempt 2 — BlurHash. Great on paper, broke in practice. BlurHash encodes an image into a ~35-character string, dropping the payload from 32 KB → ~6 KB. But BlurHash has to be decoded by JavaScript onto a <canvas>, which means:

  • nothing shows during the initial load — the blur only appears after JS runs,
  • the canvas didn't always match the image's dimensions → placeholders in the wrong position.

The entire goal is to make the page feel instant. A placeholder that waits for JavaScript defeats the purpose.

Attempt 3 — tiny Base64 + CSS background + fade. (This is what blurwind does.) Back to inline Base64, but make it tiny and render it with zero JavaScript:

  • a very small blurred image (WebP by default; PNG/AVIF/JPEG supported), encoded as a data: URI — a couple hundred bytes,
  • painted as a CSS background, so it renders immediately, on the server, with correct layout and positioning,
  • when the real image finishes loading, it fades in smoothly over the blur.

On that project the payload dropped from ~32 KB → ~13 KB (≈58% smaller), the blur was visible instantly, positioning stayed correct, and it added no new dependencies (sharp was already installed).

Large Base64 BlurHash blurwind (tiny Base64 + fade)
Payload (67 images) ~32 KB ~6 KB ~13 KB
Visible without JS
Renders on the server
Correct positioning ⚠️ often wrong
Smooth fade-in ✅ (~0.4s)
Extra dependencies plaiceholder blurhash + canvas none (sharp)

Size and format are configurable. The default blur strategy renders a 10×10 WebP; drop it to 4×4 for the absolute smallest payload, or switch formats per project. blurwind simply productizes attempt 3 — and makes the strategy pluggable so BlurHash, dominant-colour, or SVG can be opt-in choices, never the forced default.

Install

For a Next.js app, it's two commands:

pnpm add @blurwind/next      # the <Image> component  (brings @blurwind/runtime with it)
pnpm add -D @blurwind/cli     # the `blurwind generate` command (brings @blurwind/core with it)

That's everything. You don't install @blurwind/core or @blurwind/runtime yourself — they come along automatically as dependencies of the two above.

Not on Next.js? Use @blurwind/cli to generate the manifest and read it with @blurwind/runtime, or build a custom pipeline directly on @blurwind/core. Adapters for other frameworks are on the roadmap.

What's inside

blurwind is split into small pieces so you only ship what you use — a Next.js app pulls in React; a plain build script doesn't.

Package Role You install it?
@blurwind/next Drop-in <Image> for Next.js: automatic placeholder + fade-in. directly
@blurwind/cli blurwind generate / init for your build pipeline. directly (dev)
@blurwind/runtime Zero-dependency browser helper (resolve placeholder + animation). ⤵ comes with next
@blurwind/core The build-time engine: scan → load → render → manifest. Framework-free. ⤵ comes with cli

Quick start (Next.js)

After the two installs above:

1. Configureblurwind.config.ts:

import { defineConfig } from '@blurwind/cli'

export default defineConfig({
  scanDir: 'app',
  baseURL: process.env.ASSET_CDN_PATH, // omit to read from local `public/`
  output: {
    dir: 'app/constants',
    file: 'blur-data.ts',
    tsExportName: 'BLUR_DATA_URLS'
  }
})

2. Generate — wire it into your build:

{
  "scripts": {
    "prebuild": "blurwind generate"
  }
}

blurwind generate auto-loads your .env / .env.local (Next-style precedence) before reading the config, so process.env.ASSET_CDN_PATH above just works from the command line — no extra setup. If it finds image references but produces nothing (e.g. a mistyped baseURL), it prints one actionable error instead of silently shipping an empty manifest, and fails the build under --strict or CI.

3. Render — bind the manifest once, use it everywhere:

Shortcut: npx blurwind component scaffolds this file for you — with 'use client' already in place and the manifest import wired from your config. The example below is exactly what it generates; write it by hand if you prefer.

// components/Image.tsx
'use client'

import { createBlurImage } from '@blurwind/next'
import { BLUR_DATA_URLS } from '@/app/constants/blur-data'

export default createBlurImage({
  manifest: BLUR_DATA_URLS,
  baseURL: process.env.ASSET_CDN_PATH
})

Why 'use client'? @blurwind/next is a client module (it runs the blur→image transition with React state), so createBlurImage is a client reference — and the file that calls it must be a Client Component. Without the directive, the App Router treats this file as a Server Component and the build fails with:

Attempted to call createBlurImage() from the server but createBlurImage is on
the client. It's not possible to invoke a client function from the server...

This does not disable SSR: your <Image> is still server-rendered into the HTML — SEO, LCP, and lazy-loading are unaffected — the client boundary only powers the blur-to-image transition.

import Image from '@/components/Image'

<Image src="/hero.webp" alt="Hero" width={1200} height={600} />
// blurred placeholder shows instantly via next/image's native blur

It's a genuine drop-in: every next/image prop — fill, className, style, sizes, width/height — passes straight through (so <Image fill className="object-cover"> works unchanged). Prefer an animated cross-fade? Pass animation: { type: 'fade' } to createBlurImage.

How it works

 source files            @blurwind/core                        your app
┌──────────────┐   ┌───────────────────────────────┐   ┌──────────────────┐
│ *.tsx  *.mdx │   │ scanner → loader → strategy →  │   │ import manifest  │
│  "/hero.webp"│──▶│ engine  → manifest-writer      │──▶│ <Image src=… />  │
└──────────────┘   └───────────────────────────────┘   │ @blurwind/next   │
                              │  ▲                        │ + @blurwind/     │
                        sharp │  │ Strategy plugins       │   runtime        │
                              ▼  │ (blur, …)              └──────────────────┘
                       tiny blurred data: URI
  1. Scanner walks your source, extracting image references via a configurable pattern.
  2. Loader fetches each source (remote URL, CDN base, or local public/), with retries.
  3. Strategy (default blur) downscales + blurs + encodes via sharp, emitting a data: URI.
  4. Manifest writer emits a deterministic src → placeholder map (.ts and/or versioned .json).
  5. Runtime + adapter resolve the placeholder and hand it to next/image (native blur by default, or an optional motion cross-fade).

Failures are absorbed: an unreachable source reuses its previous placeholder, and if nothing can be generated the existing manifest is left untouched — your build never fails because of blur generation. A run that finds references but produces nothing still prints one actionable error, and --strict (or CI) turns it into a non-zero exit.

Extending

Strategies are plugins. Register your own to produce dominant-colour, BlurHash, SVG, or shimmer placeholders without touching the engine:

import { createGenerator, resolveConfig, type Strategy } from '@blurwind/core'

const colorStrategy: Strategy<'color'> = {
  name: 'color',
  async generate({ bytes, sharp }) {
    const { dominant } = await sharp(bytes).stats()
    const hex = `#${[dominant.r, dominant.g, dominant.b]
      .map(c => c.toString(16).padStart(2, '0'))
      .join('')}`
    return { value: hex, meta: { kind: 'color' } }
  }
}

await createGenerator(resolveConfig({ strategy: 'color' })).use(colorStrategy).run()

Configuration reference

See @blurwind/core for every option. Highlights:

Option Default Notes
scanDir 'src' Directory or directories to scan.
match broad image-URL regex Narrow to your CDN/asset paths.
baseURL '' (or $BLURWIND_BASE_URL) Prefix for relative sources; empty → local public/.
blur { size: 10, radius: 1.5, format: 'webp', quality: 40 } Built-in strategy tuning.
output src/generated/blur-manifest.ts formats: ['ts' | 'json'].
concurrency 4 Parallel images.
cache { enabled: false, dir: 'node_modules/.cache/blurwind' } Skip fetch + render for unchanged sources across builds.
failSafe true Never throw; degrade gracefully.

Troubleshooting

Attempted to call createBlurImage() from the server but createBlurImage is on the client

The file that calls createBlurImage(...) is being treated as a Server Component. @blurwind/next is a client module, so that file must start with 'use client' — see Quick start step 3. This doesn't disable SSR: the <Image> is still server-rendered into the HTML; the client boundary only powers the blur-to-image transition.

Roadmap

  • Strategies: dominant colour, BlurHash, SVG trace, shimmer.
  • More adapters: React (framework-neutral), Vue, Svelte, Astro.
  • Bundler plugins: Vite, Webpack, Rollup.
  • Docs site + interactive playground.

Development

pnpm install
pnpm build       # build every @blurwind/* package (tsup)
pnpm typecheck   # tsc --noEmit across packages
pnpm changeset   # record a change for release

License

MIT © Benyamin Khodadadi