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:
- Keep your existing server running
- Import bext and wire it into your request handler
- bext handles ISR caching, SSR rendering, and static files
- 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/imagecomponent, 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.tsdoes 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_dirinbext.config.tomlpoints to your app directory - Page files must be named
page.tsxorpage.client.tsx - API routes must be named
route.ts,route.js, orroute.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