I added a PDF export
Building a PDF export entirely above an existing repository seam using react-pdf
- nextjs
- architecture
- react-pdf
- typescript
DoodleTrove is a Next.js app: a private archive of children's artwork. This week I added a book export β pick a child, get a PDF of everything they've made, oldest first.
What makes it worth writing about is that I shipped it without changing a line of the data layer, the auth layer, or the security model. The interesting work happened entirely above a seam that already existed.
The seam did the work
From day one, every read and write in this app goes through one interface:
export interface ArtworkRepository {
listChildren(householdId: HouseholdId): Promise<Child[]>;
listArtworks(householdId: HouseholdId): Promise<Artwork[]>;
getArtwork(householdId: HouseholdId, id: ArtworkId): Promise<Artwork | null>;
// β¦create, update, delete, attachPhoto
}Note the shape: every method takes a householdId, and there is no
listAll(). Ownership scoping isn't a WHERE clause someone might forget β
it's a required parameter. The export is just a new consumer of two of those
methods. I added zero Teable code.
The security model came along for free
A book request carries a childId. The route has to prove that child belongs to
the caller's household. Because the seam is household-scoped, that proof is one
line, and a wrong childId is indistinguishable from one that doesn't exist:
async function resolveBook({ householdId, childId, repository }) {
const child = (await repository.listChildren(householdId))
.find((c) => c.id === childId);
if (!child) return { kind: "not-found" };
const artworks = (await repository.listArtworks(householdId))
.filter((a) => a.childId === childId);
const plan = planBook(child, artworks);
if (plan.includedCount > HARD_CEILING)
return { kind: "too-big", count: plan.includedCount };
return { kind: "ok", plan };
}Split the plan from the render
The book has two kinds of logic, and they have nothing in common. The planner decides content and order β what's included, where the year breaks go, which pieces to exclude for missing a photo or a date. The renderer turns that decision into PDF bytes with react-pdf.
I kept them in separate modules. The planner is a pure function over domain
types, so its branching β chronological order, year-divider placement, the
skip-and-surface rules β is asserted as plain data. The renderer is a thin
react-pdf layer that gets exactly one smoke test: feed it a representative plan,
drain the output, check it starts with %PDF- and ends with %%EOF.
The principle: test the hard logic, smoke-test the layout engine. Weaving react-pdf through every assertion would make the tests slow and brittle, and would prove nothing about the decisions that actually matter.
Streaming a PDF from a route handler
react-pdf hands you a Node stream; a Next.js Response wants a web
ReadableStream. The bridge is one call β with one type wrinkle worth knowing:
import { Readable } from "node:stream";
const nodeStream = await renderToStream(<Document>{pages}</Document>);
return new Response(Readable.toWeb(nodeStream as Readable), {
headers: { "Content-Type": "application/pdf", "Cache-Control": "no-store" },
});renderToStream is typed as NodeJS.ReadableStream (the interface), but
Readable.toWeb wants the concrete Readable class β hence the cast. At
runtime it's a real Node Readable either way.
One more practical detail: phone originals are 3β4 MB each. I normalise every
photo through sharp β resize to 1600px, re-encode as JPEG β before embedding
it as a data URI. Without that, a 50-page book is a 200 MB download. (It also
moved sharp from a dev dependency to a runtime one, since the route uses it.)
The new route is short. The interface it reads through is unchanged. That is the whole point of having put the seam there in the first place β and the first time a new feature has proven it was worth the discipline.