Migrating from Next.js to bext

Overview

bext is designed to be a drop-in replacement for Next.js App Router projects. Most of your existing code (pages, layouts, API routes, metadata) works as-is. The main differences are in configuration, middleware, and image optimization.

This guide covers the practical steps to migrate an App Router project from Next.js to bext, what stays the same, what changes, and what to watch out for.

Project Structure Mapping

Next.js bext Notes
app/layout.tsx app/layout.tsx Same -- bext scans layouts automatically
app/page.tsx app/page.tsx Same -- bext scans pages
app/loading.tsx app/loading.tsx Same -- Suspense boundary
app/error.tsx app/error.tsx Same -- Error boundary
app/not-found.tsx app/not-found.tsx Same
app/api/route.ts app/api/route.ts Same -- bext auto-discovers API routes
next.config.js bext.config.toml (server) / bext.config.ts (plugin) Different format
middleware.ts Rust middleware (server) / JS middleware (plugin) Different runtime
.env.local .env Same pattern
public/ static_dir in config Same concept, different config key

Feature Mapping

Next.js Feature bext Equivalent Status
generateMetadata export function generateMetadata Done -- detected by scanner
export const metadata export const metadata Done -- detected by scanner
generateStaticParams hasGenerateStaticParams in route map Done
ISR (revalidate) ISR cache with TTL + stale-while-revalidate + tags Done (enhanced)
On-demand revalidation POST /api/revalidate endpoint Done
Server Components RSC wire format parser Done
Server Actions "use server" transform + action endpoints Done
next/image bext image optimization API Done (different API)
next/font Font optimization transform (Google Fonts CSS, preload tags, CSS vars) Done
"use cache" directive cache_directive transform + ISR cache integration Done
after() API Background tasks on tokio runtime + /api/after/status Done
Partial Prerendering (PPR) Route scanner PPR detection + has_ppr flag Done
i18n locale routing [i18n] config: prefix/accept-language strategy, locale cache keys Done
next/og (ImageResponse) /api/og endpoint (Taffy + tiny-skia, no Chrome) Done (different API)
Streaming SSR SsrChunk + streaming pipeline Done
Route groups (group) Route groups (group) Done -- stripped from URL
Dynamic routes [param] Dynamic routes [param] Done
Catch-all [...slug] Catch-all [...slug] Done
Optional catch-all [[...slug]] Optional catch-all [[...slug]] Done
Parallel routes @slot Parallel routes @slot Done
Loading boundaries loading.tsx detection Done
Error boundaries error.tsx detection Done
ETag / 304 ETag generation + If-None-Match Done
Brotli / Gzip Automatic content encoding negotiation Done

Step-by-Step Migration

1. Install bext

bext runs in two modes. Pick the one that fits your deployment:

Plugin mode (recommended for gradual migration): bext runs as a Bun native module alongside your existing server. Your Bun/Node process stays in charge; bext handles SSR, caching, and static serving from Rust.

cd your-app
bun add bext

Server mode (standalone Rust binary): bext-server replaces your Node/Bun process entirely. Handles HTTP, SSR, caching, static files, and optionally proxies to a Bun API for data fetching.

# Build from source
cargo build -p bext-server --release

2. Create the Config File

Plugin mode (bext.config.ts):



export default {
  cache: {
    isr: {
      maxEntries: 10_000,
      defaultTtlMs: 60_000,
      defaultSwrMs: 3_600_000,
    },
    tenant: {
      ttlMs: 300_000,
    },
  },
  render: {
    jscWorkers: 4,
    bundlePath: "dist/ssr-bundle.js",
  },
} satisfies BextConfig;

Server mode (bext.config.toml):

[server]
listen = "0.0.0.0:3061"
app_dir = "src/app"
# For hybrid mode (bext handles SSR, Bun handles API/auth):
# bun_api_url = "http://localhost:3060"

[cache.isr]
max_entries = 10_000
default_ttl_ms = 60_000
default_swr_ms = 3_600_000

[render]
jsc_workers = 4
bundle_path = "dist/ssr-bundle.js"

3. Convert API Routes

Usually no changes needed. bext scans route.ts / route.js files and detects exported HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) automatically.

Verify your routes are detected:

bext-server scan

4. Update Metadata Exports

Usually no changes needed. bext detects both patterns:

// Static metadata -- detected
export const metadata = {
  title: "My Page",
  description: "...",
};

// Dynamic metadata -- detected
export async function generateMetadata({ params }) {
  return { title: `Article: ${params.slug}` };
}

5. Replace next/image

bext provides native Rust image optimization, but the component API differs:

// Before (Next.js)



// After (bext) -- use a standard img tag pointing at the optimization endpoint
<img src="/api/image?url=/photo.jpg&w=800&q=80" width={800} height={600} alt="Photo" />

Or create a wrapper component that matches the next/image API and routes through the bext optimization endpoint.

6. Configure ISR

In Next.js you set revalidate per page:

// Next.js
export const revalidate = 60; // seconds

In bext, ISR is configured globally in the config file with per-entry TTL and stale-while-revalidate. You can also control it per-request via cache tags:

[cache.isr]
default_ttl_ms = 60_000     # 1 minute (like revalidate = 60)
default_swr_ms = 3_600_000  # 1 hour stale-while-revalidate
max_entries = 10_000

For on-demand revalidation (equivalent to revalidatePath() / revalidateTag()):

# Delete cached entry (next request triggers fresh render)
curl -X POST http://localhost:3061/api/invalidate \
  -H 'Content-Type: application/json' \
  -d '{"path": "/products/123"}'

# Or invalidate by tag
curl -X POST http://localhost:3061/api/invalidate \
  -H 'Content-Type: application/json' \
  -d '{"tag": "tenant:abc123"}'

# Proactive revalidation (render NOW and store in cache)
curl -X POST http://localhost:3061/api/revalidate \
  -H 'Content-Type: application/json' \
  -d '{"path": "/products/123", "tenant_id": "abc", "site_id": "xyz"}'

7. Test with Plugin Mode First

Plugin mode lets you run bext alongside your existing Bun server. This is the safest migration path:

  1. Keep your existing server running
  2. Import bext and wire it into your request handler
  3. bext handles ISR caching, SSR rendering, and static files
  4. Your existing code handles auth, API routes, and data fetching

Once everything works in plugin mode, you can optionally switch to server mode for maximum performance.

8. Migrate "use cache" Directives

If you use Next.js 16's "use cache" directive, no code changes are needed. bext's cache_directive transform detects the directive and wraps cached functions with globalThis.__bextCache(), which integrates with bext's ISR cache layer.

bext extends the directive with parameter syntax for fine-grained control:

// Next.js 16 — works as-is (uses default ISR TTL)
"use cache";

// bext extension — specify TTL and cache tags inline
"use cache: hours=1, tag=products";

For on-demand invalidation, use /api/invalidate with the tag name:

curl -X POST http://localhost:3061/api/invalidate \
  -H 'Content-Type: application/json' \
  -d '{"tag": "products"}'

9. Migrate i18n / Locale Routing

Next.js next.config.js i18n maps to bext's [i18n] TOML section:

// next.config.js (Next.js)
module.exports = {
  i18n: {
    locales: ["en", "fr", "es"],
    defaultLocale: "en",
  },
};
# bext.config.toml
[i18n]
locales = ["en", "fr", "es"]
default_locale = "en"
strategy = "prefix"        # or "accept-language"

bext extracts locale from URL prefixes (/fr/about -> locale fr), includes locale in ISR cache keys, and sets Content-Language on responses. The accept-language strategy detects locale from the header without URL redirects.

In plugin mode, processRequest() returns a locale field extracted from the Accept-Language header.

10. Migrate Fonts

Replace next/font/google and next/font/local usage with bext's font optimization transform, which runs automatically during builds:

// This code works as-is — bext transforms it at build time

const inter = Inter({ subsets: ["latin"], weight: ["400", "700"] });

// At runtime, `inter` becomes:
// { className: "font-inter", style: { fontFamily: "'Inter', sans-serif" } }

bext generates Google Fonts @import CSS, <link rel="preload"> tags, and CSS custom properties (e.g., --font-inter) via the FontRegistry. No separate font loading library is needed.

For next/font/local, the transform produces the same object shape; you still need to serve the font files from your static directory.

11. Migrate OG Images

Replace next/og (ImageResponse) with bext's /api/og endpoint:

// Before (Next.js)

export async function GET() {
  return new ImageResponse(<div>Hello</div>);
}

// After (bext) — use the built-in endpoint directly
<meta property="og:image" content="/api/og?title=Hello&description=World" />

The /api/og endpoint accepts query parameters (title, description, bg, text, width, height) and returns PNG or JPEG with aggressive cache headers. Powered by Taffy (flexbox layout) + tiny-skia (rendering) + ab_glyph (text) -- no headless browser needed.

What's Different

Build System

  • No Turbopack -- bext uses Bun's bundler for the SSR bundle
  • Faster builds (Bun bundler is significantly faster for SSR bundles)
  • No webpack plugins needed

ISR Cache

  • In-process LRU -- no external Redis/Memcached needed
  • Stale-while-revalidate built in
  • Tag-based invalidation
  • Pre-compressed cache entries (gzip + brotli stored alongside HTML)

Image Optimization

  • Native Rust -- faster than Sharp (Node.js)
  • Different API surface (no next/image component, use endpoint directly)

Deployment Modes

  • Plugin mode: Bun + Rust NAPI -- your Bun server stays in charge
  • Server mode: Standalone Rust binary -- replaces Node/Bun entirely
  • Hybrid mode: bext-server handles SSR/static, proxies to Bun for API/data

Middleware

  • Next.js middleware.ts does not run in bext-server mode
  • Server mode has built-in Rust middleware for auth (JWT), CORS, rate limiting, and tenant resolution
  • Plugin mode: implement middleware in your Bun server as usual

Multi-Tenancy

bext has first-class multi-tenant support:

  • Tenant resolution from hostname (via cache or direct DB lookup)
  • Per-tenant ISR cache keys
  • Tenant-scoped cache invalidation via tags

What's Not Supported

Feature Status Workaround
next/font auto-subsetting Transform generates Google Fonts CSS; no local subsetting Self-host pre-subsetted fonts or rely on Google Fonts CSS2 API
middleware.ts in server mode Not supported Use Rust middleware config ([auth], [cors], [rate_limit]) or plugin mode
Turbopack HMR Not applicable bext has its own file watcher with hot-reload ([build].watch_dirs)
next/headers / next/cookies Shimmed in plugin mode Works in plugin mode; server mode uses request context

CLI Reference

bext-server includes CLI subcommands for build/validation/inspection:

bext-server              # Start the HTTP server (default)
bext-server build        # Run the Bun build script
bext-server validate     # Validate bext.config.toml
bext-server scan         # Scan app directory and print route table
bext-server version      # Print version
bext-server help         # Print usage

API Endpoints

Method Path Purpose
GET /health Health check (JSON)
GET /metrics JSC pool + cache metrics
POST /api/invalidate Delete ISR cache entries by tag or path
POST /api/revalidate Proactively re-render a page into ISR cache
GET /api/og OG image generation (query params: title, description, bg, text, width, height)
GET /api/after/status Background task counters (spawned/completed/failed)
POST /api/reload Hot-reload JSC pool from disk
POST /api/build Run build script then reload
GET /api/ssr-stream/* Streaming SSR endpoint

Performance Comparison

Typical improvements over Next.js for cached pages:

Metric Next.js bext (ISR hit) Improvement
TTFB 50-200ms 1-5ms 10-100x
Memory per worker ~150MB ~20MB 7x
Cache lookup Redis round-trip In-process LRU No network hop
Compression Per-request Pre-compressed Zero-cost on cache hit

Troubleshooting

Routes not detected by bext-server scan:

  • Check that server.app_dir in bext.config.toml points to your app directory
  • Page files must be named page.tsx or page.client.tsx
  • API routes must be named route.ts, route.js, or route.tsx
  • Directories starting with _ are excluded

ISR cache not working:

  • Verify JSC pool is active: curl http://localhost:3061/health
  • Check cache stats: curl http://localhost:3061/metrics
  • First request is always a cache miss; second request should hit

Tenant not resolved:

  • In hybrid mode, tenant info comes from the Bun API
  • In standalone mode, configure [database] for direct DB lookups
  • For development, localhost bypasses tenant resolution