<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: coloryla</title>
    <description>The latest articles on DEV Community by coloryla (@coloryla).</description>
    <link>https://dev.to/coloryla</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4190687%2F652cb932-ff51-4927-b6a0-e773798c5891.png</url>
      <title>DEV Community: coloryla</title>
      <link>https://dev.to/coloryla</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/coloryla"/>
    <language>en</language>
    <item>
      <title>Hand-rolling PDFs, taming window.print(), and finding out what Googlebot actually fetched: notes from a Next.js side project</title>
      <dc:creator>coloryla</dc:creator>
      <pubDate>Sat, 17 Oct 2026 14:57:52 +0000</pubDate>
      <link>https://dev.to/coloryla/hand-rolling-pdfs-taming-windowprint-and-finding-out-what-googlebot-actually-fetched-notes-p0d</link>
      <guid>https://dev.to/coloryla/hand-rolling-pdfs-taming-windowprint-and-finding-out-what-googlebot-actually-fetched-notes-p0d</guid>
      <description>&lt;p&gt;I run &lt;a href="https://www.coloryla.com/" rel="noopener noreferrer"&gt;Coloryla&lt;/a&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Writing a PDF without a PDF library
&lt;/h2&gt;

&lt;p&gt;Every drawing needed a PDF at a stable URL. The obvious move is &lt;code&gt;pdf-lib&lt;/code&gt; or a headless browser. Both felt heavy for "put one PNG on an A4 page".&lt;/p&gt;

&lt;p&gt;A PDF that embeds a PNG is small enough to write by hand. The route reads the PNG chunks, takes &lt;code&gt;IHDR&lt;/code&gt; for the dimensions and the concatenated &lt;code&gt;IDAT&lt;/code&gt; 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 &lt;code&gt;/FlateDecode&lt;/code&gt; with &lt;code&gt;/Predictor 15&lt;/code&gt; expects. So the bytes go into the file untouched. No re-encoding, no raster work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;A4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;595.28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;841.89&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// points&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MARGIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                   &lt;span class="c1"&gt;// ~10 mm, many home printers cannot reach the edge&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;A4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MARGIN&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;png&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;A4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MARGIN&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;png&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ... then one XObject with&lt;/span&gt;
&lt;span class="c1"&gt;// /Filter /FlateDecode /DecodeParms &amp;lt;&amp;lt; /Predictor 15 /Colors 1 /BitsPerComponent 8 /Columns ${png.width} &amp;gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route returns &lt;code&gt;Cache-Control: public, max-age=3600, s-maxage=31536000, immutable&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "Print" is three different buttons
&lt;/h2&gt;

&lt;p&gt;The naive version was &lt;code&gt;window.print()&lt;/code&gt; on the page itself. That prints the navigation, the related drawings and the cookie banner.&lt;/p&gt;

&lt;p&gt;What works: open a blank window, write a minimal HTML document with the image and a &lt;code&gt;@page { margin: 5mm }&lt;/code&gt; rule, and call &lt;code&gt;window.print()&lt;/code&gt; from the image's &lt;code&gt;onload&lt;/code&gt;. Then the browsers diverge.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Safari&lt;/strong&gt; respects &lt;code&gt;max-height: 100%&lt;/code&gt; and prints one page. Done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chromium&lt;/strong&gt; (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 &lt;code&gt;window.chrome&lt;/code&gt;, which Safari does not expose, and ship the extra rules only there. Shipping them to Safari breaks Safari.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phones&lt;/strong&gt; 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 &lt;code&gt;download&lt;/code&gt; attribute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those took a round of user reports to find. No test tells you Chrome printed on two pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. An image route that was fine locally and not fine in production
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Fixes, all boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the font is a &lt;code&gt;.ttf&lt;/code&gt; in the repo, read from disk, traced into the function bundle with &lt;code&gt;outputFileTracingIncludes&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;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;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; has a dedicated &lt;code&gt;User-agent: Googlebot&lt;/code&gt; group that excludes the route, since Google has no use for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson I keep relearning: a route meant for one crawler will be hit by every crawler.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Where did Googlebot's time go?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Disallow&lt;/code&gt; lines in &lt;code&gt;robots.txt&lt;/code&gt; fixed it, and real visitors are unaffected because browsers do not read &lt;code&gt;robots.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then the prefetch. &lt;code&gt;next/link&lt;/code&gt; 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 &lt;code&gt;Link&lt;/code&gt; wrapper sets &lt;code&gt;prefetch={false}&lt;/code&gt; by default and nothing imports &lt;code&gt;next/link&lt;/code&gt; directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;NextLink&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ComponentProps&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ComponentProps&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;NextLink&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NextLink&lt;/span&gt; &lt;span class="na"&gt;prefetch&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Asking Google one URL at a time
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://coloriagess.github.io/" rel="noopener noreferrer"&gt;plain HTML index&lt;/a&gt; on GitHub Pages, with 8 requests in flight, stores each verdict in a JSON file, and shows the result per collection and per language.&lt;/p&gt;

&lt;p&gt;Two things it taught me. Only the &lt;code&gt;PASS&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Read the crawl stats on day one. I spent my first weeks polishing pages for a bot that was busy fetching analytics beacons.&lt;/p&gt;

&lt;p&gt;If you want to see the result rather than the plumbing, the &lt;a href="https://www.coloryla.com/christmas-coloring-pages/" rel="noopener noreferrer"&gt;Christmas coloring pages&lt;/a&gt; are the newest collection and the &lt;a href="https://www.coloryla.com/dinosaur-coloring-pages/" rel="noopener noreferrer"&gt;dinosaur coloring pages&lt;/a&gt; are the ones I would print first.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>nextjs</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
