Hand-rolling PDFs, taming window.print(), and finding out what Googlebot actually fetched: notes from a Next.js side project
I run Coloryla, a free coloring pages site for kids, in English and French. It is a deliberately boring product: a drawing, a print button, a PDF. The stack is Next.js 15 on Vercel, static where possible, with a few API
I run Coloryla, a free coloring pages site for kids, in English and French. It is a deliberately boring product: a drawing, a print button, a PDF. The stack is Next.js 15 on Vercel, static where possible, with a few API routes. These are the four problems that took longer than the product itself.
1. Writing a PDF without a PDF library
Every drawing needed a PDF at a stable URL. The obvious move is pdf-lib or a headless browser. Both felt heavy for "put one PNG on an A4 page".
A PDF that embeds a PNG is small enough to write by hand. The route reads the PNG chunks, takes IHDR for the dimensions and the concatenated IDAT stream for the pixels, and wraps that stream in a PDF image object. The trick that makes it trivial: PNG scanlines are already zlib-compressed with per-row filter bytes, and that is exactly what /FlateDecode with /Predictor 15 expects. So the bytes go into the file untouched. No re-encoding, no raster work.
const A4 = { w: 595.28, h: 841.89 }; // points
const MARGIN = 28; // ~10 mm, many home printers cannot reach the edge
const scale = Math.min((A4.w - MARGIN * 2) / png.width, (A4.h - MARGIN * 2) / png.height);
// ... then one XObject with
// /Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors 1 /BitsPerComponent 8 /Columns ${png.width} >>
The route returns Cache-Control: public, max-age=3600, s-maxage=31536000, immutable, so the edge serves it after the first hit and the function almost never runs again. The whole writer is about 130 lines and has not changed since early September.
2. "Print" is three different buttons
The naive version was window.print() on the page itself. That prints the navigation, the related drawings and the cookie banner.
What works: open a blank window, write a minimal HTML document with the image and a @page { margin: 5mm } rule, and call window.print() from the image's onload. Then the browsers diverge.
-
Safari respects
max-height: 100%and prints one page. Done. -
Chromium (Chrome, Edge, Opera) spills onto a second page unless the body is a flex container with the image constrained on both axes. I detect it with
window.chrome, which Safari does not expose, and ship the extra rules only there. Shipping them to Safari breaks Safari. -
Phones are excluded from the Chromium rules entirely. The popup route is unreliable there, so the page prints itself with print-only CSS, and the download button next to it serves the PDF with a
downloadattribute.
Each of those took a round of user reports to find. No test tells you Chrome printed on two pages.
3. An image route that was fine locally and not fine in production
Each drawing has a share image generated by an API route: the line art on a card, with the title set in a display font.
First version: fetch the font from Google Fonts at request time, render, return. Locally, instant. In production, 1.5 to 3 seconds per image, and when two crawlers hit the route at the same time the function pool saturated and unrelated pages started timing out for them too.
Fixes, all boring:
- the font is a
.ttfin the repo, read from disk, traced into the function bundle withoutputFileTracingIncludes; - Redis calls get a 2 second connect timeout and a 1.5 second command timeout, so a slow cache can never hold a request open;
-
robots.txthas a dedicatedUser-agent: Googlebotgroup that excludes the route, since Google has no use for it.
The lesson I keep relearning: a route meant for one crawler will be hit by every crawler.
4. Where did Googlebot's time go?
Search Console's crawl stats report is the most underrated page in the product. Mine said that only about a quarter of Googlebot's requests were HTML. The rest was images, JavaScript and, surprisingly, a lot of JSON.
The JSON was the Vercel Web Analytics beacon. Googlebot renders pages, rendering fires the beacon, and the beacon is served under two paths, one of them an anti-adblock alias. Google was fetching more analytics beacons than pages. Two Disallow lines in robots.txt fixed it, and real visitors are unaffected because browsers do not read robots.txt.
Then the prefetch. next/link prefetches every link that enters the viewport. On a grid of thumbnails almost nothing prefetched is ever clicked, but each prefetch is a full RSC payload of about 80 KB. I measured one page triggering 36 prefetches (2.9 MB) and a long grid scrolled to the bottom triggering 172 (12.4 MB). That is a lot of bandwidth for zero clicks, and Googlebot pays it too when it renders. Now a Link wrapper sets prefetch={false} by default and nothing imports next/link directly:
import NextLink from 'next/link';
import type { ComponentProps } from 'react';
export default function Link(props: ComponentProps<typeof NextLink>) {
return <NextLink prefetch={false} {...props} />;
}
5. Asking Google one URL at a time
The Search Console UI will not tell you which of a thousand URLs are indexed. The URL Inspection API will, one URL at a time, within a quota of 2,000 inspections a day and 600 a minute. So I wrote a small Node server with no dependencies: it walks the sitemap, the same list I keep as a plain HTML index on GitHub Pages, with 8 requests in flight, stores each verdict in a JSON file, and shows the result per collection and per language.
Two things it taught me. Only the PASS verdict is stable; the "discovered" and "unknown to Google" states flip between two calls a second apart, so do not build logic on them. And 7 seconds per inspection is normal, which puts a full pass at a quarter of an hour. Run it, do not watch it.
What I would do differently
Read the crawl stats on day one. I spent my first weeks polishing pages for a bot that was busy fetching analytics beacons.
If you want to see the result rather than the plumbing, the Christmas coloring pages are the newest collection and the dinosaur coloring pages are the ones I would print first.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.