Build This Now
Build This Now
What Is Claude CodeInstallationNative InstallerFirst Project
speedy_devvkoen_salo
Blog/Handbook/Workflow/PDF Invoices

PDF Invoices

Generate a branded PDF invoice in Next.js the moment Stripe fires invoice.paid: render with React-PDF inside an Inngest step, store it in a private Supabase bucket, email it through Resend, and serve billing history behind signed URLs.

Want the framework behind these builds?

Get the Claude Code system we use to plan, build, test, and ship production software.

See what we build for companies →
speedy_devvkoen_salo
speedy_devvWritten by speedy_devvPublished Jul 30, 202610 min readHandbook hubWorkflow index

A customer pays, and 20 seconds later a branded PDF receipt lands in their inbox and shows up in their billing history. No cron job scanning for new payments, no PDF service, no headless Chrome. This guide builds that pipeline in Next.js 16: React-PDF renders the document inside an Inngest step, Supabase Storage holds it in a private bucket, Resend attaches it to the email, and signed URLs hand it back on demand.

What Runs When

The chain has one entry point and four steps. Stripe fires invoice.paid. Your webhook verifies the signature, resolves the Stripe customer to one of your users, sends a single Inngest event, and returns. Everything expensive happens after that, off the request path.

Inside the Inngest function, step one pulls the invoice detail from Stripe. Step two renders the PDF and uploads it to a private bucket. Step three writes a row to your invoices table with the storage path. Step four downloads the file back out and emails it through Resend. Each step is a durable checkpoint, so a Resend outage re-runs only the email, never the render.

That ordering is deliberate. Storage comes before the email because the file is the source of truth and the email is a courtesy copy. If you email first and the upload fails, the customer has a receipt your app cannot show them, which is the worst version of this feature. Writing the ledger row before the email also means billing history is correct even if the customer's mail provider bounces every message you send.

Claude Code will happily wire this up in one shot if you tell it the shape first. A short brief in CLAUDE.md keeps it from reaching for Puppeteer or making the bucket public:

## Invoice PDFs

- Trigger: Stripe invoice.paid webhook -> inngest.send -> generate-invoice-pdf.
- Webhook does signature verification and nothing else. No rendering in the route.
- Rendering uses @react-pdf/renderer renderToBuffer. Never Puppeteer or Chromium.
- Bucket "invoices" is PRIVATE. Files live at {userId}/{invoiceNumber}.pdf.
- Buffers never cross an Inngest step boundary. Store first, re-download later.
- Downloads are signed URLs (60s), minted only after an RLS-checked ownership read.

The Bucket and the Ledger

Two pieces of state. A private storage bucket for the bytes, and a table that records what was issued so billing history does not depend on listing files.

The table stores the Stripe invoice id with a unique constraint, which is what makes the whole pipeline idempotent. If Stripe redelivers the event, the upsert lands on the same row and the file is overwritten with identical content. Row level security lets a user read only their own invoices, and there is deliberately no insert or update policy: writes come from the service role inside the Inngest function.

-- supabase/migrations/20260730_invoices.sql
create table public.invoices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users (id) on delete cascade,
  stripe_invoice_id text not null unique,
  number text not null,
  currency text not null,
  amount_paid_cents integer not null,
  pdf_path text,
  issued_at timestamptz not null,
  created_at timestamptz not null default now()
);

create index invoices_user_issued_idx
  on public.invoices (user_id, issued_at desc);

alter table public.invoices enable row level security;

create policy "users read own invoices"
  on public.invoices for select
  to authenticated
  using (auth.uid() = user_id);

insert into storage.buckets (id, name, public)
values ('invoices', 'invoices', false)
on conflict (id) do nothing;

create policy "users read own invoice files"
  on storage.objects for select
  to authenticated
  using (
    bucket_id = 'invoices'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

The storage path is worth a second of thought before you write any code, because changing it later means migrating files. Putting the owner's user id first ({userId}/{invoiceNumber}.pdf) is what makes the storage policy above a one-line check: Supabase exposes storage.foldername(name) so the policy can compare the first path segment to auth.uid(). A flat namespace of invoice numbers would force a join against your table on every read, which storage policies cannot do cheaply.

The pipeline needs a Supabase client that bypasses RLS, because the Inngest function has no user session. Keep it in its own module so nothing in the client bundle can ever import it by accident.

// lib/supabase/admin.ts
import { createClient } from "@supabase/supabase-js";

export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false, autoRefreshToken: false } },
);

The Invoice Document

React-PDF is a React renderer that outputs PDF instead of DOM. You write components, it produces bytes. Version 4 supports React 19, and Next.js already opts the package out of server bundling, so no next.config.ts change is needed in most projects.

Install it alongside the rest of the pipeline:

npm install @react-pdf/renderer inngest resend stripe @supabase/supabase-js
npm install --save-dev inngest-cli

The document is a plain component tree. Document wraps Page, and inside you use View and Text with a Flexbox subset. There is no cascade and no class names, so styles come from StyleSheet.create and get applied per element. Money is stored in cents everywhere and only formatted at the last moment.

// lib/pdf/invoice-document.tsx
import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer";

export interface InvoiceLine {
  description: string;
  quantity: number;
  unitAmountCents: number;
  amountCents: number;
}

export interface InvoiceData {
  number: string;
  issuedAt: string;
  currency: string;
  customerName: string;
  customerEmail: string;
  issuerName: string;
  issuerAddress: string;
  footer: string;
  lines: InvoiceLine[];
  subtotalCents: number;
  taxCents: number;
  totalCents: number;
}

const styles = StyleSheet.create({
  page: { padding: 44, fontSize: 10, fontFamily: "Helvetica", color: "#18181b" },
  bar: { height: 6, backgroundColor: "#4f46e5", marginBottom: 28 },
  header: { flexDirection: "row", justifyContent: "space-between", marginBottom: 32 },
  brand: { fontSize: 18, fontFamily: "Helvetica-Bold" },
  muted: { color: "#71717a" },
  right: { textAlign: "right" },
  parties: { flexDirection: "row", justifyContent: "space-between", marginBottom: 28 },
  label: { fontSize: 8, letterSpacing: 1, color: "#71717a", marginBottom: 4 },
  row: { flexDirection: "row", paddingVertical: 7 },
  headRow: { borderBottomWidth: 1, borderBottomColor: "#18181b", paddingBottom: 5 },
  bodyRow: { borderBottomWidth: 1, borderBottomColor: "#e4e4e7" },
  cDesc: { flex: 4 },
  cQty: { flex: 1, textAlign: "right" },
  cUnit: { flex: 1.4, textAlign: "right" },
  cAmt: { flex: 1.4, textAlign: "right" },
  totals: { marginTop: 18, marginLeft: "auto", width: 220 },
  totalRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 4 },
  grand: { fontFamily: "Helvetica-Bold", borderTopWidth: 1, borderTopColor: "#18181b", marginTop: 6, paddingTop: 8 },
  footer: { position: "absolute", bottom: 32, left: 44, right: 44, fontSize: 8, color: "#71717a" },
});

function money(cents: number, currency: string) {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: currency.toUpperCase(),
  }).format(cents / 100);
}

export function InvoiceDocument({ data }: { data: InvoiceData }) {
  return (
    <Document title={`Invoice ${data.number}`} author={data.issuerName}>
      <Page size="A4" style={styles.page}>
        <View style={styles.bar} />

        <View style={styles.header}>
          <View>
            <Text style={styles.brand}>{data.issuerName}</Text>
            <Text style={styles.muted}>{data.issuerAddress}</Text>
          </View>
          <View style={styles.right}>
            <Text style={styles.brand}>Invoice {data.number}</Text>
            <Text style={styles.muted}>Issued {data.issuedAt}</Text>
            <Text style={styles.muted}>Paid in full</Text>
          </View>
        </View>

        <View style={styles.parties}>
          <View>
            <Text style={styles.label}>BILLED TO</Text>
            <Text>{data.customerName}</Text>
            <Text style={styles.muted}>{data.customerEmail}</Text>
          </View>
        </View>

        <View style={[styles.row, styles.headRow]}>
          <Text style={styles.cDesc}>Description</Text>
          <Text style={styles.cQty}>Qty</Text>
          <Text style={styles.cUnit}>Unit</Text>
          <Text style={styles.cAmt}>Amount</Text>
        </View>

        {data.lines.map((line, index) => (
          <View key={index} style={[styles.row, styles.bodyRow]}>
            <Text style={styles.cDesc}>{line.description}</Text>
            <Text style={styles.cQty}>{line.quantity}</Text>
            <Text style={styles.cUnit}>{money(line.unitAmountCents, data.currency)}</Text>
            <Text style={styles.cAmt}>{money(line.amountCents, data.currency)}</Text>
          </View>
        ))}

        <View style={styles.totals}>
          <View style={styles.totalRow}>
            <Text style={styles.muted}>Subtotal</Text>
            <Text>{money(data.subtotalCents, data.currency)}</Text>
          </View>
          <View style={styles.totalRow}>
            <Text style={styles.muted}>Tax</Text>
            <Text>{money(data.taxCents, data.currency)}</Text>
          </View>
          <View style={[styles.totalRow, styles.grand]}>
            <Text>Total paid</Text>
            <Text>{money(data.totalCents, data.currency)}</Text>
          </View>
        </View>

        <Text style={styles.footer} fixed>
          {data.footer}
        </Text>
      </Page>
    </Document>
  );
}

A few constraints are worth internalizing before you fight the layout. There is no cascade, so a style on a parent View does not reach the Text inside it. Flexbox is supported but Grid is not, which is why the line item table is built from rows of flexed columns rather than a real table. The style prop accepts an array, and later entries win, which is how the header and body rows share a base row style. And position: "absolute" combined with the fixed prop on the footer is what pins the legal line to the bottom of every page instead of letting it float after the last line item.

Your company name, address, and legal footer do not change per invoice, so they should not be fetched per invoice either. This helper is a good fit for "use cache". Note that cached functions cannot read cookies, which is exactly why it uses the admin client rather than a request-scoped one.

// lib/billing/issuer.ts
import { cacheLife, cacheTag } from "next/cache";
import { supabaseAdmin } from "@/lib/supabase/admin";

export async function getIssuerProfile() {
  "use cache";
  cacheLife("days");
  cacheTag("issuer-profile");

  const { data } = await supabaseAdmin
    .from("billing_settings")
    .select("issuer_name, issuer_address, invoice_footer")
    .single();

  return {
    issuerName: data?.issuer_name ?? "Acme Inc.",
    issuerAddress: data?.issuer_address ?? "1 Market St, San Francisco, CA",
    footer: data?.invoice_footer ?? "Thanks for your business. Questions? billing@acme.com",
  };
}

Rendering Inside an Inngest Step

Here is the detail that costs people an afternoon. Everything a step.run returns gets serialized to JSON so Inngest can checkpoint it. A Node Buffer does not survive that trip, and neither do Date objects or Map instances. So the render and the upload live in the same step, and that step returns only the storage path (a string). The email step downloads the file back out of storage when it needs the bytes.

Because the function contains JSX, the file has to be a .tsx file. That is easy to miss when everything else in your Inngest folder is .ts.

// lib/inngest/functions/generate-invoice-pdf.tsx
import { renderToBuffer } from "@react-pdf/renderer";
import { Resend } from "resend";
import { inngest } from "@/lib/inngest/client";
import { stripe } from "@/lib/stripe";
import { supabaseAdmin } from "@/lib/supabase/admin";
import { getIssuerProfile } from "@/lib/billing/issuer";
import { InvoiceDocument } from "@/lib/pdf/invoice-document";

const resend = new Resend(process.env.RESEND_API_KEY!);

export const generateInvoicePdf = inngest.createFunction(
  { id: "generate-invoice-pdf", retries: 4, concurrency: { limit: 5 } },
  { event: "billing/invoice.paid" },
  async ({ event, step }) => {
    const { stripeInvoiceId, userId } = event.data;

    const data = await step.run("load-invoice", async () => {
      const invoice = await stripe.invoices.retrieve(stripeInvoiceId);
      const issuer = await getIssuerProfile();

      const subtotal = invoice.subtotal ?? 0;
      const total = invoice.total ?? 0;
      const paidAt = invoice.status_transitions?.paid_at ?? invoice.created;

      return {
        number: invoice.number ?? stripeInvoiceId,
        issuedAt: new Date(paidAt * 1000).toISOString().slice(0, 10),
        currency: invoice.currency,
        customerName: invoice.customer_name ?? "Customer",
        customerEmail: invoice.customer_email ?? "",
        issuerName: issuer.issuerName,
        issuerAddress: issuer.issuerAddress,
        footer: issuer.footer,
        lines: invoice.lines.data.map((line) => {
          const quantity = line.quantity ?? 1;
          return {
            description: line.description ?? "Subscription",
            quantity,
            unitAmountCents: quantity > 0 ? Math.round(line.amount / quantity) : line.amount,
            amountCents: line.amount,
          };
        }),
        subtotalCents: subtotal,
        taxCents: total - subtotal,
        totalCents: invoice.amount_paid,
      };
    });

    const pdfPath = await step.run("render-and-store", async () => {
      const buffer = await renderToBuffer(<InvoiceDocument data={data} />);
      const path = `${userId}/${data.number}.pdf`;

      const { error } = await supabaseAdmin.storage
        .from("invoices")
        .upload(path, buffer, { contentType: "application/pdf", upsert: true });

      if (error) throw new Error(`Upload failed: ${error.message}`);
      return path;
    });

    await step.run("record-invoice", async () => {
      const { error } = await supabaseAdmin.from("invoices").upsert(
        {
          user_id: userId,
          stripe_invoice_id: stripeInvoiceId,
          number: data.number,
          currency: data.currency,
          amount_paid_cents: data.totalCents,
          pdf_path: pdfPath,
          issued_at: new Date(data.issuedAt).toISOString(),
        },
        { onConflict: "stripe_invoice_id" },
      );

      if (error) throw new Error(`Ledger write failed: ${error.message}`);
    });

    await step.run("email-receipt", async () => {
      if (!data.customerEmail) return { skipped: true };

      const { data: file, error } = await supabaseAdmin.storage
        .from("invoices")
        .download(pdfPath);

      if (error || !file) throw new Error("Could not read the stored PDF");
      const buffer = Buffer.from(await file.arrayBuffer());

      await resend.emails.send({
        from: `${data.issuerName} Billing <billing@acme.com>`,
        to: data.customerEmail,
        subject: `Your receipt ${data.number}`,
        html: `<p>Thanks for your payment. Your receipt is attached, and every invoice is available in your billing history.</p>`,
        attachments: [{ filename: `${data.number}.pdf`, content: buffer }],
      });

      return { skipped: false };
    });

    return { pdfPath };
  },
);

Resend accepts an attachment as an object with filename and content, where content is a Buffer or a base64 string, with a 40MB ceiling per email after encoding. An invoice PDF is a few kilobytes, so that limit only matters if you ever batch a year of them into one message.

The client and the serve route are the standard Inngest setup. The serve handler exports all three HTTP methods so Inngest can introspect the function list as well as invoke it.

// lib/inngest/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({ id: "billing-app" });
// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import { generateInvoicePdf } from "@/lib/inngest/functions/generate-invoice-pdf";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [generateInvoicePdf],
});

The Webhook That Starts It

The webhook does three things and nothing more: verify the signature against the raw body, map the Stripe customer to your user, and send one event. Passing an id on the Inngest event gives you deduplication, so a redelivered Stripe webhook does not produce a second run.

// app/api/stripe/webhook/route.ts
import { headers } from "next/headers";
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { inngest } from "@/lib/inngest/client";
import { supabaseAdmin } from "@/lib/supabase/admin";

export async function POST(request: Request) {
  const body = await request.text();
  const signature = (await headers()).get("stripe-signature");

  if (!signature) {
    return new Response("Missing signature", { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  if (event.type === "invoice.paid") {
    const invoice = event.data.object;
    const customerId =
      typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;

    if (customerId) {
      const { data: profile } = await supabaseAdmin
        .from("profiles")
        .select("id")
        .eq("stripe_customer_id", customerId)
        .maybeSingle();

      if (profile) {
        await inngest.send({
          name: "billing/invoice.paid",
          id: `invoice-paid-${invoice.id}`,
          data: { stripeInvoiceId: invoice.id, userId: profile.id },
        });
      }
    }
  }

  return Response.json({ received: true });
}

Serving Billing History Behind Signed URLs

Reads go through oRPC so the query shape is validated once and the page compiles against the same contract. The handler uses the request-scoped Supabase client, which means the RLS policy is what actually enforces ownership. The explicit user_id filter is there for index selectivity, not security.

// server/orpc/billing.ts
import { os, ORPCError } from "@orpc/server";
import { z } from "zod";
import { createClient } from "@/lib/supabase/server";

export interface Context {
  userId: string;
}

const base = os.$context<Context>();

export const listInvoices = base
  .input(z.object({ limit: z.number().int().min(1).max(100).default(24) }))
  .handler(async ({ input, context }) => {
    const supabase = await createClient();

    const { data, error } = await supabase
      .from("invoices")
      .select("id, number, currency, amount_paid_cents, issued_at, pdf_path")
      .eq("user_id", context.userId)
      .order("issued_at", { ascending: false })
      .limit(input.limit);

    if (error) {
      throw new ORPCError("INTERNAL_SERVER_ERROR", { message: error.message });
    }

    return data ?? [];
  });

export const router = { listInvoices };

The download route awaits params as Next.js 16 requires, reads the row through the user's own client so RLS decides whether they may see it, then mints a 60 second signed URL with the service role client. The download option sets the filename the browser saves, which is the difference between invoice-2026-0142.pdf and a random storage key.

// app/api/invoices/[id]/download/route.ts
import { createClient } from "@/lib/supabase/server";
import { supabaseAdmin } from "@/lib/supabase/admin";

interface RouteContext {
  params: Promise<{ id: string }>;
}

export async function GET(_request: Request, { params }: RouteContext) {
  const { id } = await params;

  const supabase = await createClient();
  const { data: claims } = await supabase.auth.getClaims();
  if (!claims?.claims.sub) {
    return new Response("Unauthorized", { status: 401 });
  }

  const { data: invoice } = await supabase
    .from("invoices")
    .select("number, pdf_path")
    .eq("id", id)
    .maybeSingle();

  if (!invoice?.pdf_path) {
    return new Response("Not found", { status: 404 });
  }

  const { data: signed, error } = await supabaseAdmin.storage
    .from("invoices")
    .createSignedUrl(invoice.pdf_path, 60, { download: `${invoice.number}.pdf` });

  if (error || !signed) {
    return new Response("Not found", { status: 404 });
  }

  return Response.redirect(signed.signedUrl, 302);
}

The billing page itself is a Server Component. It must not carry "use cache", because the data is per-user and session dependent. Caching it would be the exact bug this whole design exists to prevent.

// app/(app)/billing/page.tsx
import { createRouterClient } from "@orpc/server";
import { router } from "@/server/orpc/billing";
import { createContext } from "@/server/orpc/context";

export default async function BillingPage() {
  const client = createRouterClient(router, { context: await createContext() });
  const invoices = await client.listInvoices({ limit: 24 });

  return (
    <main className="mx-auto max-w-2xl py-12">
      <h1 className="mb-6 text-2xl font-bold">Billing history</h1>
      <ul className="divide-y divide-border rounded-lg border">
        {invoices.map((invoice) => (
          <li key={invoice.id} className="flex items-center justify-between p-4">
            <div>
              <p className="font-medium">{invoice.number}</p>
              <p className="text-sm text-muted-foreground">
                {new Date(invoice.issued_at).toLocaleDateString()}
              </p>
            </div>
            <div className="flex items-center gap-4">
              <span className="tabular-nums">
                {new Intl.NumberFormat("en-US", {
                  style: "currency",
                  currency: invoice.currency.toUpperCase(),
                }).format(invoice.amount_paid_cents / 100)}
              </span>
              <a
                href={`/api/invoices/${invoice.id}/download`}
                className="text-sm font-medium underline"
              >
                Download
              </a>
            </div>
          </li>
        ))}
      </ul>
    </main>
  );
}

Testing the Chain Locally

Three terminals and one command replay the whole pipeline without waiting for a real customer. The Stripe CLI forwards live test-mode events to your local webhook, and the Inngest dev server picks up the function.

npm run dev
npx inngest-cli dev -u http://localhost:3000/api/inngest
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger invoice.paid

Open the Inngest dev UI at http://localhost:8288 and you can see each step, its output, and its timing. When something fails, you get the exact step that threw and the ability to replay from there instead of re-triggering Stripe. Ask Claude Code to run the trigger and read the run output, and it will trace a failing upload back to a missing bucket policy on its own.

The Gotchas That Bite

Fonts. React-PDF ships with the standard PDF fonts (Helvetica, Times, Courier). Anything else needs Font.register with a TTF the renderer can load at runtime, and a font that fails to load produces a blank document rather than an error. Start with Helvetica and add a brand font only once the pipeline is green.

Zero-decimal currencies. Dividing by 100 is wrong for JPY, KRW, and a handful of others where the smallest unit is the whole unit. If you sell in those, branch on the currency before formatting instead of assuming two decimal places.

Invoice numbers as paths. Stripe invoice numbers are unique per account and safe in a path, but only if you fall back to the invoice id when number is null (which happens on draft invoices). The code above does that fallback deliberately.

Tax fields drift. Stripe has reshaped its tax fields across API versions. Deriving tax as total minus subtotal, as the function does, keeps working across version bumps. Read the specific field only if you need the breakdown per rate.

Line items are paginated. invoice.lines is a list object, and Stripe returns a bounded page of it, not necessarily everything. For a normal subscription charge with one or two lines this never matters. For a metered or seat-heavy invoice it silently truncates the PDF, which is the kind of bug you find from a customer email. If your invoices can run long, page through the line items explicitly instead of reading invoice.lines.data once.

Function limits on serverless. Rendering is CPU work happening inside the Inngest step, which runs in your Next.js function. A one-page invoice renders fast, but a 40-page one with a registered font will push both memory and duration. Keep an eye on your function configuration, and if documents get large, split the render across steps by page group so no single invocation carries the whole job.

Do not put invoices in a public bucket. A public bucket means anyone who guesses a path reads a customer's name, email, and purchase history. Private bucket plus a signed URL that expires in 60 seconds is barely more code and a completely different risk profile.

Where the Harness Fits

A pipeline like this is five files that all have to agree: the webhook shape, the event payload, the PDF props, the storage path, and the table columns. Claude Code writes any one of them well, and the failure mode is drift between them. That is what the $29 Code Kit is for. It is a one-time purchase, no subscription, and it is a harness on top of Claude Code rather than a replacement for it (you still need a paid Anthropic plan). The loop is plan, build, evaluate, test, then quality gates that do not negotiate: zero type errors, zero lint errors, clean build. On a pipeline where a wrong type only shows up when a real customer pays, having that run automatically after every hand-off is the difference between shipping it and babysitting it.

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: How to Build an App AI Agents Can Pay For
    A plain-English guide to agentic commerce in 2026: what x402, ACP, and the Machine Payments Protocol do, plus a weekend walkthrough for shipping a paid API that AI agents can buy from.
  • Claude Code Best Practices
    Five habits separate engineers who ship with Claude Code: PRDs, modular CLAUDE.md rules, custom slash commands, /clear resets, and a system-evolution mindset.
  • Claude Code Auto Mode
    A second Sonnet model reviews every Claude Code tool call before it fires. What auto mode blocks, what it allows, and the allow rules it drops in your settings.
  • Channels, Routines, Teleport, Dispatch
    The four Claude Code features Anthropic shipped in March and April 2026 that turn the CLI into an event-driven coordination layer across phone, web, and desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Agent Fundamentals
    Five ways to build specialist agents in Claude Code: Task sub-agents, .claude/agents YAML, custom slash commands, CLAUDE.md personas, and perspective prompts.
  • Agent Harness Engineering
    The harness is every layer around your AI agent except the model itself. Learn the five control levers, the constraint paradox, and why harness design determines agent performance more than the model does.
  • Agent Patterns
    Orchestrator, fan-out, validation chain, specialist routing, progressive refinement, and watchdog. Six orchestration shapes to wire Claude Code sub-agents with.
  • Agent Teams Best Practices
    Battle-tested patterns for Claude Code Agent Teams. Context-rich spawn prompts, right-sized tasks, file ownership, delegate mode, and v2.1.33-v2.1.45 fixes.

Want the framework behind these builds?

Get the Claude Code system we use to plan, build, test, and ship production software.

See what we build for companies →
speedy_devvkoen_salo

On this page

What Runs When
The Bucket and the Ledger
The Invoice Document
Rendering Inside an Inngest Step
The Webhook That Starts It
Serving Billing History Behind Signed URLs
Testing the Chain Locally
The Gotchas That Bite
Where the Harness Fits

Want the framework behind these builds?

Get the Claude Code system we use to plan, build, test, and ship production software.

See what we build for companies →