jarrah

Recipe

Build an export button in 10 minutes.

Two route handlers and one component. Your users click Export, they get a styled .xlsx, and you never add a spreadsheet library to your app or a spike to your memory graph.

What you build

The round trip, once, so the code below is obvious:

StepWho talks to whom
1Browser posts to your route. No key involved.
2Your server streams the rows to Jarrah and gets a job id back.
3Browser polls your route until the job succeeds.
4Your server swaps the job for a signed link and returns it.
5Browser downloads from object storage directly.
The file never passes through your server. Step 5 goes straight to storage on a signed link, so a 200 MB export costs you no bandwidth and holds no request open.

1. Start the export

app/api/export/route.js. The rows are streamed as they come out of your database, so neither your process nor ours holds the whole dataset.

// app/api/export/route.js
const JARRAH = "https://api.jarrah.sh";

// Your data. An async generator, so rows are never all in memory at once.
async function* orders() {
  for (let i = 1; i <= 5000; i++) {
    yield [`Customer ${i}`, i * 1.5, "2026-08-17", "paid"];
  }
}

// NDJSON: one header line describing the workbook, then one line per row.
function ndjsonStream(header, rowsIterable) {
  const encode = new TextEncoder();
  const rows = rowsIterable[Symbol.asyncIterator]();
  return new ReadableStream({
    start(controller) {
      controller.enqueue(encode.encode(JSON.stringify(header) + "\n"));
    },
    async pull(controller) {
      const { value, done } = await rows.next();
      if (done) controller.close();
      else controller.enqueue(encode.encode(JSON.stringify(value) + "\n"));
    },
  });
}

export async function POST(request) {
  // Authenticate the user here, exactly as you would for any other route.
  const header = {
    styles: { money: { num_fmt: "#,##0.00" } },
    sheets: [{
      name: "Orders",
      columns: [
        { header: "Customer" },
        { header: "Amount", style: "money" },
        { header: "Issued" },
        { header: "Status" },
      ],
    }],
  };

  const res = await fetch(`${JARRAH}/v1/jobs`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.JARRAH_API_KEY}`,
      "Content-Type": "application/x-ndjson",
    },
    body: ndjsonStream(header, orders()),
    duplex: "half",
  });

  const job = await res.json();
  return Response.json({ id: job.id });
}
duplex: "half" is not optional. Node refuses a streaming request body without it. Leave it out and you get a type error rather than a slow upload, which is the better failure.

2. Poll for the file

app/api/export/[id]/route.js. One subtlety here does the real work, and it is the reason this page exists.

// app/api/export/[id]/route.js
const JARRAH = "https://api.jarrah.sh";

export async function GET(request, { params }) {
  const { id } = await params;
  const auth = { Authorization: `Bearer ${process.env.JARRAH_API_KEY}` };

  const job = await fetch(`${JARRAH}/v1/jobs/${id}`, { headers: auth })
    .then((r) => r.json());

  if (job.status !== "succeeded") {
    return Response.json({ status: job.status });
  }

  // The download endpoint answers 303 to a pre-signed link.
  // redirect: "manual" stops fetch following it, so we can hand the link
  // to the browser instead of pulling the file through this server.
  const dl = await fetch(`${JARRAH}${job.download_url}`, {
    headers: auth,
    redirect: "manual",
  });

  return Response.json({
    status: "succeeded",
    url: dl.headers.get("location"),
  });
}
Do not follow the redirect. Without redirect: "manual" your server downloads the whole file and then has to send it on, which turns a signed link into a proxy and puts the memory problem back where you started. The signed link is valid for fifteen minutes and carries its own signature, so treat it as a credential and hand it out rather than storing it.

3. The button

A client component. It never sees a Jarrah key, only your own routes.

"use client";
import { useState } from "react";

export default function ExportButton() {
  const [busy, setBusy] = useState(false);

  async function run() {
    setBusy(true);
    try {
      const { id } = await fetch("/api/export", { method: "POST" })
        .then((r) => r.json());

      for (;;) {
        const view = await fetch(`/api/export/${id}`).then((r) => r.json());
        if (view.status === "succeeded") {
          window.location.href = view.url;
          return;
        }
        if (view.status === "failed") throw new Error("export failed");
        await new Promise((r) => setTimeout(r, 500));
      }
    } finally {
      setBusy(false);
    }
  }

  return (
    <button onClick={run} disabled={busy}>
      {busy ? "Preparing…" : "Export to Excel"}
    </button>
  );
}

That is the whole thing. Setting window.location.href to the signed link starts a normal browser download, filename and all, with no blob handling and nothing held in a tab.

Where the key lives

JARRAH_API_KEY goes in your server environment and nowhere else. There is no publishable key, no browser-side token, and no client SDK that wants one. If you find yourself reaching for NEXT_PUBLIC_JARRAH_KEY, something has gone wrong: a key in the browser is a key anyone can read and spend.

The browser only ever talks to your routes, which is also where your own authorisation belongs. Jarrah has no idea which of your users asked for the file, and should not.

Large exports

Nothing above changes for a million rows. The generator yields, the stream carries, and the file lands in storage. What changes is how long the polling runs, so keep the button honest about it.

Measured on the code exactly as printed, against production: 5,000 rows submitted, rendered and downloaded in 1.8 seconds end to end from a machine in Australia, of which the render itself was about 520 ms. Most of the rest is the Pacific.

For small exports there is a synchronous endpoint that hands the file back in the response and skips the polling entirely. It has a per-plan cell ceiling, so anything that might grow belongs on the job path above. See the quickstart.

What it costs

One export is one document, whatever its size. A ten-row file and a ten-million-row file bill the same, and there are no per-developer or per-seat charges to reconcile at renewal.

The free tier is 100 documents a month with no card, which is enough to build this and run it in staging. Get a key, or read the full API.

Every block on this page was run before it was published. The code was extracted back out of this file and executed against api.jarrah.sh, which is the only way a recipe is worth copying.