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:
| Step | Who talks to whom |
|---|---|
| 1 | Browser posts to your route. No key involved. |
| 2 | Your server streams the rows to Jarrah and gets a job id back. |
| 3 | Browser polls your route until the job succeeds. |
| 4 | Your server swaps the job for a signed link and returns it. |
| 5 | Browser downloads from object storage directly. |
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"),
});
}
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.
api.jarrah.sh, which is the only way a recipe is worth
copying.