← Back to Learn

Tutorials

9 project-based tutorials. Click any row to expand.

Blog with ISRBeginner
Markdown blog with incremental static regeneration.
20 min

ISR lets you serve static HTML but re-generate pages in the background when content changes. Create a posts/ directory with .md files and expose them through a dynamic route.

// bext.config.ts
export default {
  isr: {
    revalidate: 60, // seconds
    paths: ["/blog/*"],
  },
};

The page component reads markdown at build time. On the first request after the revalidate window, bext serves the stale page instantly and regenerates in the background.

// src/app/blog/[slug]/page.tsx
import { readFile } from "fs/promises";
import { marked } from "marked";

export async function generateStaticParams() {
  const files = await readdir("posts");
  return files.map((f) => ({ slug: f.replace(".md", "") }));
}

export default async function Post({ params }: { params: { slug: string } }) {
  const md = await readFile(`posts/${params.slug}.md`, "utf-8");
  return <article dangerouslySetInnerHTML={{ __html: marked(md) }} />;
}

Deploy and request any post. The first hit triggers a build, subsequent requests serve the cached page. After 60 seconds the next visitor triggers a background regeneration.

JWT AuthIntermediate
Login flow with refresh tokens and protected routes.
25 min

bext middleware runs before the route handler, making it the right place for auth checks. Start by creating a login endpoint that issues a short-lived access token and a long-lived refresh token.

// src/app/api/auth/login/route.ts
import { sign } from "jsonwebtoken";

export async function POST(req: Request) {
  const { email, password } = await req.json();
  const user = await db.verifyCredentials(email, password);
  if (!user) return new Response("Unauthorized", { status: 401 });

  const access = sign({ sub: user.id }, process.env.JWT_SECRET!, { expiresIn: "15m" });
  const refresh = sign({ sub: user.id, type: "refresh" }, process.env.JWT_SECRET!, {
    expiresIn: "7d",
  });

  return Response.json({ access, refresh });
}

Create middleware that verifies the token on every request to protected routes. Expired access tokens return 401 so the client can call /api/auth/refresh.

// src/middleware.ts
import { verify } from "jsonwebtoken";

export default function middleware(req: Request) {
  if (!req.url.includes("/api/protected")) return;

  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
  if (!token) return new Response("Missing token", { status: 401 });

  try {
    verify(token, process.env.JWT_SECRET!);
  } catch {
    return new Response("Invalid token", { status: 401 });
  }
}

The refresh endpoint issues a new access token when the client sends a valid refresh token. Store revoked refresh tokens in Redis for instant invalidation.

Realtime DashboardIntermediate
Live metrics via Server-Sent Events.
30 min

bext has built-in SSE support through its realtime feature flag. Create a route that streams metric snapshots to connected clients every second.

// bext.config.ts
export default {
  features: ["realtime"],
};
// src/app/api/metrics/stream/route.ts
export function GET() {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    start(controller) {
      const interval = setInterval(async () => {
        const metrics = {
          cpu: os.loadavg()[0],
          mem: process.memoryUsage().heapUsed,
          rps: await redis.get("rps:current"),
          timestamp: Date.now(),
        };
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify(metrics)}\n\n`)
        );
      }, 1000);

      // Clean up when client disconnects
      return () => clearInterval(interval);
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

On the client, connect with EventSource and update your chart on each message. bext handles connection keep-alive and automatic reconnection at the infrastructure level.

// src/app/dashboard/page.tsx
"use client";
import { useEffect, useState } from "react";

export default function Dashboard() {
  const [metrics, setMetrics] = useState<any>(null);

  useEffect(() => {
    const es = new EventSource("/api/metrics/stream");
    es.onmessage = (e) => setMetrics(JSON.parse(e.data));
    return () => es.close();
  }, []);

  if (!metrics) return <div>Connecting...</div>;
  return (
    <div>
      <span>CPU: {metrics.cpu.toFixed(2)}</span>
      <span>Heap: {(metrics.mem / 1e6).toFixed(0)} MB</span>
      <span>RPS: {metrics.rps}</span>
    </div>
  );
}
WASM PluginAdvanced
Image processor in Rust compiled to WASM.
35 min

bext loads .wasm files from the plugins/ directory and exposes them as callable functions. Write a Rust crate that resizes images, then compile it to wasm32-wasi.

// plugins/image-resize/src/lib.rs
use image::{load_from_memory, ImageFormat};
use std::io::Cursor;

#[no_mangle]
pub extern "C" fn resize(input: *const u8, len: usize, width: u32, height: u32) -> *const u8 {
    let bytes = unsafe { std::slice::from_raw_parts(input, len) };
    let img = load_from_memory(bytes).unwrap();
    let resized = img.resize_exact(width, height, image::imageops::Lanczos3);
    let mut buf = Cursor::new(Vec::new());
    resized.write_to(&mut buf, ImageFormat::WebP).unwrap();
    let result = buf.into_inner();
    let ptr = result.as_ptr();
    std::mem::forget(result);
    ptr
}

Build with cargo build --target wasm32-wasi --release and copy the .wasm file into your plugins directory. Register it in your bext config.

// bext.config.ts
export default {
  plugins: [
    {
      name: "image-resize",
      runtime: "wasm",
      path: "./plugins/image-resize/target/wasm32-wasi/release/image_resize.wasm",
    },
  ],
};

Call the plugin from any route handler. bext instantiates the WASM module once and reuses it across requests with near-native performance.

// src/app/api/resize/route.ts
import { callPlugin } from "bext/plugins";

export async function POST(req: Request) {
  const body = await req.arrayBuffer();
  const width = Number(req.headers.get("X-Width") || 800);
  const height = Number(req.headers.get("X-Height") || 600);

  const resized = await callPlugin("image-resize", "resize", {
    input: new Uint8Array(body),
    width,
    height,
  });

  return new Response(resized, {
    headers: { "Content-Type": "image/webp" },
  });
}
Next.js MigrationIntermediate
Move an e-commerce storefront to bext.
25 min

bext supports Next.js conventions out of the box when you enable compat mode. Start by updating your config to point at your existing source directory.

// bext.config.ts
export default {
  compat: "full",       // enable Next.js layout/page conventions
  srcDir: "./src",
  env: {
    NEXT_PUBLIC_API_URL: process.env.API_URL,
  },
};

Replace next/image with standard <img> tags or bext's built-in image optimization. Replace next/link with plain <a> tags — bext handles client-side navigation automatically for internal links.

// Before (Next.js)
import Image from "next/image";
import Link from "next/link";

export default function ProductCard({ product }) {
  return (
    <Link href={`/products/${product.slug}`}>
      <Image src={product.image} width={400} height={300} alt={product.name} />
      <h3>{product.name}</h3>
    </Link>
  );
}

// After (bext)
export default function ProductCard({ product }) {
  return (
    <a href={`/products/${product.slug}`}>
      <img src={product.image} width={400} height={300} alt={product.name} />
      <h3>{product.name}</h3>
    </a>
  );
}

Remove next.config.js and next from your dependencies. Run bext dev — your app should work with the existing file structure. API routes, layouts, and dynamic segments carry over without changes.

Multi-tenant RoutingAdvanced
Subdomain routing with per-tenant databases.
20 min

Use bext middleware to extract the tenant from the hostname and attach it to the request context. Each tenant gets its own database connection string stored in a central lookup table.

// src/middleware.ts
export default async function middleware(req: Request) {
  const host = req.headers.get("host") || "";
  const tenant = host.split(".")[0]; // acme.example.com -> acme

  const config = await redis.hget("tenants", tenant);
  if (!config) return new Response("Unknown tenant", { status: 404 });

  const parsed = JSON.parse(config);
  // Attach tenant context via headers for downstream handlers
  const headers = new Headers(req.headers);
  headers.set("X-Tenant-Id", parsed.id);
  headers.set("X-Tenant-DB", parsed.databaseUrl);

  return new Request(req.url, { headers, method: req.method, body: req.body });
}

In your route handlers, read the tenant context from headers and connect to the correct database. Use connection pooling per tenant to avoid exhausting connections.

// src/lib/db.ts
import { Pool } from "pg";

const pools = new Map<string, Pool>();

export function getPool(req: Request): Pool {
  const dbUrl = req.headers.get("X-Tenant-DB")!;
  if (!pools.has(dbUrl)) {
    pools.set(dbUrl, new Pool({ connectionString: dbUrl, max: 10 }));
  }
  return pools.get(dbUrl)!;
}

// src/app/api/products/route.ts
import { getPool } from "@/lib/db";

export async function GET(req: Request) {
  const pool = getPool(req);
  const { rows } = await pool.query("SELECT * FROM products ORDER BY created_at DESC");
  return Response.json(rows);
}

Register new tenants by adding entries to the Redis hash. bext routes requests to the correct handler with no restarts needed — wildcard DNS and auto-TLS handle the rest.

Auto TLSBeginner
HTTPS certificates with zero config.
15 min

bext provisions TLS certificates automatically on first request using ACME (Let's Encrypt). The default config has TLS enabled — you only need to point your DNS and bext handles the rest.

// bext.config.ts
export default {
  tls: {
    auto: true,               // default: true
    redirectHttp: true,        // 301 redirect HTTP -> HTTPS
    domains: [
      "example.com",
      "www.example.com",
    ],
  },
};

For custom domains (e.g. in a SaaS), add them dynamically. bext provisions certificates on demand and caches them to disk.

// src/app/api/domains/route.ts
import { tls } from "bext/runtime";

export async function POST(req: Request) {
  const { domain } = await req.json();

  // Validate domain ownership (e.g. check DNS CNAME)
  const cname = await dns.resolveCname(domain);
  if (!cname.includes("app.example.com")) {
    return new Response("CNAME not configured", { status: 400 });
  }

  // Tell bext to provision a cert for this domain
  await tls.addDomain(domain);

  return Response.json({ status: "provisioning", domain });
}

Certificates renew automatically 30 days before expiry. Check certificate status at /_bext/tls in development mode. In production, bext logs renewal events to stdout.

Built-in Package ManagerBeginner
Replace npm/yarn/pnpm with bext's built-in Rust-native package manager.
10 min

bext ships with a full npm-compatible package manager powered by the utoo toolchain. No Node.js or npm install required — everything runs from the bext binary.

# First, download the package manager companion
bext download ut

# Install dependencies from an existing project
bext install

# Add a new dependency
bext install lodash

# Add a dev dependency
bext install -D typescript

# Run scripts from package.json
bext ut run dev

# Execute a package binary (like npx)
bext x prettier --write .

The package manager reads and writes standard package-lock.json files, so switching from npm is seamless. Your existing lockfile works as-is.

# Full command reference
bext install [pkg...]     # Install deps (aliases: bext i, bext add)
bext uninstall <pkg...>   # Remove deps (alias: bext un)
bext x <cmd>              # Run package binary (alias: bext execute)
bext link [pkg]           # Link packages (alias: bext ln)
bext view <pkg>           # View package info
bext publish              # Publish to registry

# All utoo PM commands via pass-through
bext ut run <script>      # Run package.json scripts
bext ut init              # Create package.json
bext ut list <pkg>        # List dependencies
bext ut config list       # Show PM configuration
bext ut clean             # Clean package cache

For the bundler, use bext pack. It runs Turbopack under the hood for Rust-native build performance.

# Download the bundler companion
bext download ut/pack

# Production build
bext pack --mode build --project-path .

# Dev server with watch mode
bext pack --mode dev --watch true
Durable WorkflowsAdvanced
Background jobs with retries and checkpoints.
30 min

Durable workflows survive process restarts. Define a workflow as an async generator — each yield is a checkpoint that bext persists. If the process crashes, the workflow resumes from the last checkpoint.

// src/workflows/process-order.ts
import { workflow } from "bext/workflows";

export const processOrder = workflow("process-order", async function* (orderId: string) {
  // Step 1: Validate inventory
  const items = yield* validateInventory(orderId);

  // Step 2: Charge payment (idempotent)
  const charge = yield* chargePayment(orderId, items.total);
  if (!charge.ok) throw new Error("Payment failed");

  // Step 3: Ship order
  yield* createShipment(orderId, items);

  // Step 4: Send confirmation email
  yield* sendConfirmation(orderId, charge.receiptUrl);

  return { orderId, status: "completed" };
});

Trigger the workflow from any route handler. bext returns a workflow ID you can poll for status. Each step retries up to 3 times by default with exponential backoff.

// src/app/api/orders/route.ts
import { processOrder } from "@/workflows/process-order";

export async function POST(req: Request) {
  const { orderId } = await req.json();

  const run = await processOrder.start(orderId);

  return Response.json({ workflowId: run.id, status: run.status });
}

// src/app/api/orders/[id]/status/route.ts
import { processOrder } from "@/workflows/process-order";

export async function GET(_req: Request, { params }: { params: { id: string } }) {
  const run = await processOrder.get(params.id);
  return Response.json({
    status: run.status,      // "running" | "completed" | "failed"
    currentStep: run.step,   // last completed checkpoint index
    result: run.result,      // available when completed
  });
}

Configure retry behavior and timeouts per workflow. Failed workflows move to a dead-letter queue you can inspect and replay.

// Override defaults per workflow
export const processOrder = workflow("process-order", handler, {
  retries: 5,
  backoff: "exponential",
  timeout: "10m",
  onFailure: "dead-letter",
});
Learning paths · Quick tips