<?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: RadarMakers</title>
    <description>The latest articles on DEV Community by RadarMakers (@radarmakers).</description>
    <link>https://dev.to/radarmakers</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%2F4195863%2F6bf25e0c-de5a-4cbd-85b9-ebe9994973c3.png</url>
      <title>DEV Community: RadarMakers</title>
      <link>https://dev.to/radarmakers</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/radarmakers"/>
    <language>en</language>
    <item>
      <title>Why Apache returned 502 for our Go uploads</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Tue, 10 Nov 2026 01:22:58 +0000</pubDate>
      <link>https://dev.to/radarmakers/why-apache-returned-502-for-our-go-uploads-5gp4</link>
      <guid>https://dev.to/radarmakers/why-apache-returned-502-for-our-go-uploads-5gp4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4amj1hgrzva9xc40rpor.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4amj1hgrzva9xc40rpor.png" alt="The declared request size limit is 5 MiB and the capped discard attempt is 25 MiB. Source: internal/handler/upload.go." width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxd01fyvke6u7hmuppxw8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxd01fyvke6u7hmuppxw8.png" alt="The upload handler allowlist contains four image extensions and omits SVG and WebP. Source: internal/handler/upload.go." width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An oversized upload should give someone a useful rejection. In our case, the browser received "Upload failed: 502" even though the Go handler had a file-too-large response. The affected site was &lt;a href="https://radarmakers.com?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=why-apache-returned-502-for-our-go-uploads" rel="noopener noreferrer"&gt;RadarMakers&lt;/a&gt;, a free launch platform for indie makers, with Apache forwarding uploads to Go.&lt;/p&gt;

&lt;p&gt;The interesting part was the difference between the application response and the response that reached the browser. The workaround checks the declared request length before parsing multipart data, attempts a bounded discard of an oversized body, then writes the rejection.&lt;/p&gt;

&lt;p&gt;This account comes from the current handler and its Git history. The earlier failure is documented there; I have not reproduced it for this article. The code explains a specific workaround, with limits that matter as much as the happy path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the proxy changed
&lt;/h2&gt;

&lt;p&gt;The failure was not simply that the application rejected a large file. The browser saw a 502 from Apache when the Go server reset the connection while the proxy was still forwarding the body. A direct request to the API had produced the handler response instead. That distinction is why the investigation followed the request across both hops.&lt;/p&gt;

&lt;p&gt;Start a similar incident by capturing the browser response, the proxy error log, and the application log for the same request window. Compare a request sent through the public route with one sent directly to the service in a controlled environment. Keep the same method, headers, body, and authentication state. If the two paths differ, inspect how the proxy handles upstream resets and unread request bytes before changing the application status code.&lt;/p&gt;

&lt;p&gt;This case record does not claim that every 502 during an upload has this cause. A proxy can return a gateway error for many upstream failures. Here the repository comment identifies the reset during body forwarding, and the recorded comparison showed a different response on the direct path. Those are the facts behind this particular workaround.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an early length check helped
&lt;/h2&gt;

&lt;p&gt;The handler checks the declared Content-Length before wrapping the body or calling the multipart parser. When that value exceeds the request threshold, it takes a rejection path without parsing the form. This avoids spending image parsing work on a request that the server already intends to reject.&lt;/p&gt;

&lt;p&gt;The header is only an early signal. A client can omit the length or send a value that does not describe the bytes that arrive. The source therefore keeps a second guard with &lt;code&gt;http.MaxBytesReader&lt;/code&gt; before &lt;code&gt;ParseMultipartForm&lt;/code&gt;. The Go documentation describes that reader as a request body limit that returns a &lt;code&gt;MaxBytesError&lt;/code&gt; after a read crosses the configured size, and says it may tell the response writer to close the connection if possible.&lt;/p&gt;

&lt;p&gt;The two checks answer different questions. The first decides whether the declared size already exceeds the limit and can be rejected before parsing. The reader limit protects the path where the header cannot make that decision. Neither check proves that the image is safe to decode. Content length limits transport size; image validation has separate responsibilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drain is bounded for a reason
&lt;/h2&gt;

&lt;p&gt;On the declared oversized path, the handler attempts to discard up to five times the body limit. With the current constant, that is a maximum read attempt of 25 MiB. The &lt;code&gt;io.CopyN&lt;/code&gt; call writes to &lt;code&gt;io.Discard&lt;/code&gt;, so these bytes are consumed for connection handling and are not stored as an upload.&lt;/p&gt;

&lt;p&gt;A bounded drain is a tradeoff, not a universal recipe. Reading a portion of a rejected body can give a proxy a chance to finish forwarding bytes already in flight, but an unbounded read would let an oversized client keep the handler occupied for an arbitrary amount of data. The cap makes the maximum discard request explicit. The code ignores the returned byte count and error, then writes the 400 response, so it does not claim that every oversized body was fully consumed.&lt;/p&gt;

&lt;p&gt;The comparison is important when changing the constants. Raising the file limit also changes the allowed request size and may change the maximum drain if the multiplier stays fixed. Increasing only the drain cap changes how much rejected traffic the handler may read. These values should be reviewed separately and tested with the proxy configuration that actually fronts the service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request bodies without a trustworthy length
&lt;/h2&gt;

&lt;p&gt;HTTP request bodies can arrive without a useful declared size. The handler still wraps the body with &lt;code&gt;MaxBytesReader&lt;/code&gt;, so the multipart parse cannot consume an unbounded body through this path. The same fallback also matters if the declared value is smaller than the bytes actually read. The header check is therefore an optimization and early rejection, not the sole security boundary.&lt;/p&gt;

&lt;p&gt;A test suite should cover both known and unknown lengths. For a declared body above the threshold, assert that the early branch returns the intended status and message without parsing a multipart field. For a body whose declared size does not cross the threshold but whose actual bytes exceed it, assert that the reader limit controls the result. If the request uses chunked transfer, exercise the actual server and proxy path because middleware and proxy buffering can affect which component sees the limit first.&lt;/p&gt;

&lt;p&gt;Avoid writing a test that only calls the handler directly and then declaring the production 502 fixed. The original difference occurred across Apache and Go. A direct handler test can verify the application branch, but it cannot verify how a deployed proxy handles a reset. Keep those claims separate in test names and release notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A transport limit is not a memory limit
&lt;/h2&gt;

&lt;p&gt;The configured limit is applied to the request body and multipart parsing. After the file is read, the handler also decodes and re-encodes the image. A compressed image can represent far more pixels than its byte size suggests. That is why the code checks the image header before decoding all pixels, and why the comment explains the risk of an extremely large image dimension.&lt;/p&gt;

&lt;p&gt;The header buffer in the handler is 64 KiB. &lt;code&gt;io.ReadFull&lt;/code&gt; attempts to fill that buffer, while treating EOF and unexpected EOF as conditions that can still leave a usable short header. The &lt;code&gt;checkImage&lt;/code&gt; call receives only the bytes actually read. If the header cannot be parsed, the handler returns a client error before calling &lt;code&gt;sanitizeImage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A request size limit and a dimension check protect different resources. The first bounds incoming body bytes. The second rejects an image whose decoded dimensions would require unreasonable memory. Re-encoding then creates a cleaned output from pixels rather than copying the original file bytes. These operations should remain independently visible in code review because a change to one does not remove the need for the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extension policy is part of the safety design
&lt;/h2&gt;

&lt;p&gt;The current allowlist accepts PNG, JPG, JPEG, and GIF after lowercasing the filename extension. SVG is deliberately absent because it is an active document format when served from the same origin. WebP is also absent because this implementation uses the standard library image path and re-encodes supported pixels. The allowlist is not a statement that every file with an accepted suffix is valid; the image decoder and sanitizer still inspect its contents.&lt;/p&gt;

&lt;p&gt;A filename suffix is user supplied. It should not be treated as proof of the media type. The handler reads a header, checks it against the expected extension, seeks back to the start, reads the file, and sanitizes the decoded content. If a new format is added, test mismatched suffixes, malformed headers, oversized dimensions, and the final saved output. Also confirm that the chosen decoder and encoder remove metadata that should not be retained.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to observe in production
&lt;/h2&gt;

&lt;p&gt;Instrument the rejection path without logging the file contents or sensitive request data. Useful fields include the route, the outcome category, whether the declared size crossed the threshold, and whether the request had a usable length. Avoid recording raw filenames if they may contain personal data. A counter by outcome can help detect a burst of rejected uploads, but it cannot show whether the proxy delivered the intended response. For that, compare proxy status and application status in a request trace with a safe correlation identifier.&lt;/p&gt;

&lt;p&gt;Keep the logs explicit about boundaries. The application can record that it wrote a 400 response. The proxy log can record what it returned to the client. If those values differ, the path between the two is the subject of the next investigation. Do not collapse them into one field called upload status.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review the implementation as a set of invariants
&lt;/h2&gt;

&lt;p&gt;The existing order expresses a set of useful invariants. Authentication is checked before upload processing. Declared oversized bodies are rejected before multipart parsing. Other bodies are wrapped in a byte limit. The multipart form must contain the expected file field. The extension must be allowed. Image data is checked from its header before a full decode. The source bytes are re-read from the start for sanitization, then only the cleaned result is saved.&lt;/p&gt;

&lt;p&gt;When refactoring, preserve the ordering or explain which property has changed. A helper that moves parsing above the length check may reintroduce the expensive path. A helper that copies original bytes to disk may preserve metadata or unsafe content. A broader format list may bypass the established sanitizer. Write a test for each boundary that matters rather than asserting only that a valid PNG succeeds.&lt;/p&gt;

&lt;p&gt;The documented workaround is deliberately narrow. It changes the oversized declared-length path so the handler can attempt a capped drain before writing its intended response. It does not claim to solve every proxy timeout, every malformed multipart form, or every malicious image. That narrow description is more useful to the next person debugging a similar symptom than a general promise that uploads are now safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two charts from the implementation record
&lt;/h2&gt;

&lt;p&gt;The threshold chart shows the exact request limit and drain ceiling read from &lt;code&gt;internal/handler/upload.go&lt;/code&gt;. The format chart counts the extensions in the source allowlist: four accepted suffixes and two explicitly absent formats discussed in the code comments. These are implementation facts, not measurements of traffic or an estimate of risk. The diagrams already in the asset folder remain useful for request flow and the branch order; the charts add the actual values that a reader may want to compare with a proposed code change.&lt;/p&gt;

&lt;p&gt;Before changing either number, rerun the relevant tests and inspect the deployment proxy limits, upload timeout, and body buffering configuration. This repository record documents the Go handler and its comments. It does not establish the settings of every reverse proxy or the behavior of every client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist for investigating a proxy upload error
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Compare the public route and direct service response with the same request.&lt;/li&gt;
&lt;li&gt;[ ] Correlate proxy and application logs for one request.&lt;/li&gt;
&lt;li&gt;[ ] Check whether the content length is present and whether it matches the bytes sent.&lt;/li&gt;
&lt;li&gt;[ ] Test the early rejection and the reader limit as separate paths.&lt;/li&gt;
&lt;li&gt;[ ] Keep the rejected body drain capped and explain the cap.&lt;/li&gt;
&lt;li&gt;[ ] Verify body size and decoded image dimensions independently.&lt;/li&gt;
&lt;li&gt;[ ] Confirm the file header and extension agree before sanitizing.&lt;/li&gt;
&lt;li&gt;[ ] Save only the re-encoded output when that is the security policy.&lt;/li&gt;
&lt;li&gt;[ ] Test through the deployed proxy before claiming the client response is fixed.&lt;/li&gt;
&lt;li&gt;[ ] Record which observations are from source, tests, or production logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The same upload had different outcomes
&lt;/h2&gt;

&lt;p&gt;Commit &lt;code&gt;33cb05b&lt;/code&gt;, dated August 26, 2026, records an oversized upload returning 502 through Apache and 400 when sent directly to the API. That comparison is the historical observation. It places the discrepancy between the direct API response and the response delivered through the proxy.&lt;/p&gt;

&lt;p&gt;The handler comments describe the mechanism: the body limit was crossed while Apache was still forwarding the upload, the Go connection reset, and Apache answered the browser with a bare 502. The intended rejection lost its useful explanation along the way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9td7zh1h3mfvhzixi4gr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9td7zh1h3mfvhzixi4gr.png" alt="Apache returns 502 after a Go connection reset while forwarding an upload." width="800" height="736"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the documented behavior of this deployment. It should not be read as a universal rule that every call to &lt;code&gt;MaxBytesReader&lt;/code&gt; immediately resets a connection. The &lt;a href="https://pkg.go.dev/net/http#MaxBytesReader" rel="noopener noreferrer"&gt;Go documentation for MaxBytesReader&lt;/a&gt; describes a reader that errors when reads exceed the limit and, where possible, tells the response writer to close the connection afterward.&lt;/p&gt;

&lt;p&gt;The distinction matters when investigating a similar symptom. A browser's status describes what arrived at the browser. Reading the handler's error branch alone does not establish that its response survived the proxy path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limit applies to the request body
&lt;/h2&gt;

&lt;p&gt;The size constant remains small and explicit. Excerpt from &lt;code&gt;internal/handler/upload.go&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;maxFileSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="c1"&gt;// 5MB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expression is 5 MiB. The comment and user-facing message say MB, but the binary count is what the code enforces. More precisely, this is a request body limit. A multipart upload includes framing and fields as well as the file, so it is not an exact allowance of 5 MiB for image bytes alone.&lt;/p&gt;

&lt;p&gt;The handler first checks authentication and returns 401 if claims are absent. The length decision discussed here happens after that check. It is not a description of how an anonymous request is processed.&lt;/p&gt;

&lt;p&gt;Choosing this body threshold is an application design decision. The source supplies the value; it does not establish that this is the right threshold for every upload service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reject by declared length before parsing
&lt;/h2&gt;

&lt;p&gt;The added branch sits before the reader wrapper and multipart parser. Excerpt from &lt;code&gt;internal/handler/upload.go&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ContentLength&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxFileSize&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CopyN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Discard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;maxFileSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;writeJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusBadRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"file too large (max 5MB)"&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;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decision uses the declared length before reading body bytes. Once that length exceeds the threshold, the handler already knows which response it intends to send. It does not parse the multipart form or inspect the image on this branch.&lt;/p&gt;

&lt;p&gt;It still reads from the body before responding. Those bytes go to &lt;code&gt;io.Discard&lt;/code&gt;; they are not accepted as an upload. The purpose, stated in the comments, is to let the 400 travel back through Apache while accommodating data the client has already sent.&lt;/p&gt;

&lt;p&gt;The selected status is 400, with the existing file-too-large message. That is the application's response choice. The patch changes how the handler reaches that response rather than increasing the accepted upload size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drain has its own ceiling
&lt;/h2&gt;

&lt;p&gt;Multiplying the body threshold by five gives a discard cap of 25 MiB. That value is a separate design decision: the comments explicitly reject unbounded draining as a way for a client to make the server keep reading indefinitely.&lt;/p&gt;

&lt;p&gt;The operation attempts to copy up to that amount from the body. The &lt;a href="https://pkg.go.dev/io#CopyN" rel="noopener noreferrer"&gt;Go documentation for CopyN&lt;/a&gt; says it copies the requested count or stops on an error, returning the copied count and an error. This handler ignores both return values and proceeds to write 400.&lt;/p&gt;

&lt;p&gt;For bodies whose actual size matches their declared size, a body over 5 MiB and no larger than 25 MiB fits within the drain budget. An earlier read error can still prevent a complete drain. A body larger than 25 MiB can leave bytes unread after the cap is reached.&lt;/p&gt;

&lt;p&gt;Those consequences follow from the code. They do not establish that every oversized upload now avoids 502. The handler attempts a bounded accommodation of the proxy path; it does not verify that all bytes were consumed or guarantee delivery of its response.&lt;/p&gt;

&lt;p&gt;A byte cap also should not be described as a time limit. This branch specifies an amount to discard, not a duration. Any claim about complete timeout protection would need evidence beyond these lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unknown lengths keep the reader guard
&lt;/h2&gt;

&lt;p&gt;The declared length check is not the only protection. Requests that bypass it still encounter the original wrapper and parser. Excerpt from &lt;code&gt;internal/handler/upload.go&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MaxBytesReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxFileSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ParseMultipartForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxFileSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;writeJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusBadRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"file too large (max 5MB)"&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;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This includes requests without a declared length and those declaring a length at or below the threshold. The nearby comment also identifies an inaccurate declaration as a reason to retain the reader guard.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbvvdnjorjxeeegz6oq9u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbvvdnjorjxeeegz6oq9u.png" alt="Declared body length selects a 5 MiB reader limit or a capped drain before 400." width="800" height="944"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The branches therefore serve different purposes. Declared oversize selects the capped drain and rejection. Otherwise, actual reading remains bounded by &lt;code&gt;MaxBytesReader&lt;/code&gt; before multipart parsing. Removing that wrapper would discard the protection for requests the length check cannot reject in advance.&lt;/p&gt;

&lt;p&gt;There is another detail in the error handling: every error returned by this multipart parsing call maps to the same file-too-large message. Seeing that message is not, by itself, proof that the reader limit caused the parse error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is verified and what remains open
&lt;/h2&gt;

&lt;p&gt;The history documents the earlier proxy failure. The current source contains the declared-length branch, the 25 MiB discard cap, and the retained 5 MiB reader limit. These are inspectable implementation facts.&lt;/p&gt;

&lt;p&gt;The graphics README reports no regression test for the declared-length drain or Apache workaround in this checkout. Nearby image tests cover other concerns. They do not demonstrate that this response reaches a browser through Apache, and no such regression coverage is claimed here.&lt;/p&gt;

&lt;p&gt;For a similar investigation, compare direct and proxied responses, then inspect what happens to unread request data when the handler rejects it. Keep the acceptance threshold, drain budget, and unknown-length behavior explicit. This patch makes those choices visible, while leaving a clear boundary around what its code and historical observation can support.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Go net/http documentation for MaxBytesReader: &lt;/li&gt;
&lt;li&gt;Go io documentation for CopyN: &lt;/li&gt;
&lt;li&gt;RadarMakers source: internal/handler/upload.go and the recorded change 33cb05b dated August 26, 2026.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>go</category>
      <category>debugging</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Live alternatives to a well known tool, only after five real matches</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Sat, 31 Oct 2026 05:02:57 +0000</pubDate>
      <link>https://dev.to/radarmakers/live-alternatives-to-a-well-known-tool-only-after-five-real-matches-3okl</link>
      <guid>https://dev.to/radarmakers/live-alternatives-to-a-well-known-tool-only-after-five-real-matches-3okl</guid>
      <description>&lt;p&gt;A page of alternatives is easy to fake. Pick a famous product, list anything from the same category, and the URL looks useful. On &lt;a href="https://radarmakers.com/alternatives?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=live-alternatives-to-a-well-known-tool-only-after-five-real-matches" rel="noopener noreferrer"&gt;RadarMakers&lt;/a&gt; the directory does the opposite. A tool page is added only when at least five live products do the same job as one well known tool. A shared category or a shared tag is not enough.&lt;/p&gt;

&lt;p&gt;This post uses the directory as it was served on 28 October 2026. The hub returned HTTP 200. Its visible sentence was: "No alternatives page is published yet, because no well known tool has five live matches in the catalog." Requests for &lt;code&gt;/alternatives/notion&lt;/code&gt; returned 404. The same is true for any other tool slug. There is a route, and there is no published tool page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the hub actually says
&lt;/h2&gt;

&lt;p&gt;The title is "Alternatives directory". The description says a page is added when at least five live products do the same job as one well known tool.&lt;/p&gt;

&lt;p&gt;The introduction says the maker tagline and the pricing label come from the listing. It also says each published page has its own introduction, a comparison table, and answers to common questions. The order on a page follows community votes, then overall score, then the earlier launch.&lt;/p&gt;

&lt;p&gt;Three questions are on the hub:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When is an alternatives page published? When five or more live products do the same job as one well known tool. A shorter list stays off the directory.&lt;/li&gt;
&lt;li&gt;Does the table review the well known tool? No. The table shows RadarMakers products only. It uses the maker tagline and pricing label. It does not score the named tool and it does not add features the listing does not state.&lt;/li&gt;
&lt;li&gt;How can a product appear on a list? Submit it for free with a confirmed account and wait for review. The reviewer checks that the website matches the listing. The product joins a page only when its job matches that tool and at least five live products qualify.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those sentences are the page. They are not a claim that five substitutes exist for any named product today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why five, and why the job matters
&lt;/h2&gt;

&lt;p&gt;The constant in &lt;code&gt;frontend/src/lib/saas-alternatives.ts&lt;/code&gt; is &lt;code&gt;MIN_SAAS_ALTERNATIVES = 5&lt;/code&gt;. A target is a hand-written record: a slug, a name, an introduction, questions, and a list of product slugs. The publisher keeps a target only when at least five of those slugs are live. If the list is empty, the hub renders the empty sentence and the tool route calls &lt;code&gt;notFound&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The hand-written list is the point. Two products in "AI Tools" can both be live and still do different jobs. Putting them on a "ChatGPT alternatives" page because they share a category would invent a comparison the listings do not support. The code comment says not to add a tool that has four matches.&lt;/p&gt;

&lt;p&gt;On 28 October 2026 the target list was empty. That is why the sitemap on that day listed &lt;code&gt;https://radarmakers.com/alternatives&lt;/code&gt; and no tool URLs under it. The hub entry changed two days later, as described further down.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a tool page would show
&lt;/h2&gt;

&lt;p&gt;The unpublished template still describes the table. The headings are Product, Tagline, and Pricing label. Each product name links to &lt;code&gt;/product/&amp;lt;slug&amp;gt;&lt;/code&gt;. The pricing cell uses the maker label already stored on the product: free, freemium, paid, or open source. There is no extra price, no feature grid, and no winner.&lt;/p&gt;

&lt;p&gt;The page would also repeat its own questions as FAQ structured data. None of that HTML is on the public site until a target qualifies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Images that are live, and images that wait
&lt;/h2&gt;

&lt;p&gt;The hub Open Graph image is &lt;code&gt;/og/alternatives.png&lt;/code&gt;, 1200 by 630. Its alt text is "Discover alternatives | RadarMakers". The Twitter card is a separate file, &lt;code&gt;/og/alternatives-x.png&lt;/code&gt;, 1200 by 675, with the alt text "Discover alternatives on RadarMakers. Tools from indie makers." Both returned HTTP 200 as &lt;code&gt;image/png&lt;/code&gt; on 28 October 2026. The small header illustration is &lt;code&gt;/illustrations/saas-alternatives/alternatives-header.svg&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Five taller cards exist for Notion, Trello, Canva, Zapier, and Calendly. They are 1200 by 675 and they are reachable as image files. They are not attached to a tool page, because those pages return 404. A template card that still says "Tool Name" is not published.&lt;/p&gt;

&lt;p&gt;A recheck on 31 October 2026 gave the same picture. All seven image files, the two hub images and the five named cards, answered HTTP 200 as PNG files at the stated sizes. All five named tool routes still answered 404.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7tlkohim86xdc67p4kqs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7tlkohim86xdc67p4kqs.png" alt="Image files and named tool routes on October 31, 2026: seven images served, zero tool pages served, five tool routes returning 404." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: HTTPS requests to radarmakers.com, checked October 31, 2026 at 04:55 UTC. Chart: RadarMakers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The tool route, when a page exists, uses the generic Open Graph image. It uses a named Twitter card only for a slug that has one of those five files. Every other slug uses the generic Twitter card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links from the catalog
&lt;/h2&gt;

&lt;p&gt;A product page can link to a tool page with the sentence "See this product on the {name} alternatives page". A category page can list the same tool pages when one of its products is on that list, and the product card in that category can show the same link. On 28 October 2026 those links had nothing to point at, because no tool page was published. The hub itself is linked from the footer and from &lt;code&gt;/tools&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate every candidate before counting it
&lt;/h2&gt;

&lt;p&gt;The threshold is necessary, but it does not establish that five products solve the same problem. Start with a plain language job statement. A shared category label is not enough. A tool that includes a notes feature is not automatically a replacement for a knowledge base. A design product with export is not automatically an image editor.&lt;/p&gt;

&lt;p&gt;For every candidate, open the public product site and its directory listing. Confirm that it is live and that the available description supports the proposed comparison. Record the date and evidence. Exclude a product when the listing does not support the claimed use. Do not fill a missing detail with a guess. The five distinct live product IDs must be counted after status and duplicate checks.&lt;/p&gt;

&lt;p&gt;This method also makes future maintenance possible. Products can change purpose, become unavailable, or leave the directory. Revisit each candidate set after material listing changes. If fewer than five still qualify, the page should stop presenting itself as an eligible comparison under the policy. A saved spreadsheet or a short review record is enough to make the decision reproducible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribute the fields the table actually shows
&lt;/h2&gt;

&lt;p&gt;The template uses product name, maker supplied tagline, and pricing label. Explain that narrow scope. The page does not independently review the named commercial tool and does not score a winner. A tagline is short maker supplied copy, not a verified feature specification. A label such as free, freemium, paid, or open source is not a price schedule.&lt;/p&gt;

&lt;p&gt;If you add a new field, define its source and update method. A feature grid can appear authoritative while its claims become stale. A smaller table with current attributable facts may be more useful than a broad matrix built from uncertain details. Link readers to the product page for current terms. Do not infer an amount, usage cap, trial, or entitlement from a pricing label.&lt;/p&gt;

&lt;p&gt;A method note should say what makes a product eligible, when records were checked, which fields come from the listing, and how a maker can suggest a correction. If the list is curated, explain the rule without implying independent testing. Disclose a connection when it could affect how the page should be interpreted.&lt;/p&gt;

&lt;p&gt;State the inspection date and correction route. When a maker asks why a product appears, the team can point to the same job definition and evidence standard. Keep private correspondence private. Disclose an editorial or commercial connection when it affects how a reader should interpret the comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep an empty hub honest and useful
&lt;/h2&gt;

&lt;p&gt;An empty hub can explain the inclusion rule and link to broader discovery. It should not imply that comparison pages exist when they do not. Make the empty state useful by explaining what a future page would contain and where a reader can browse now. Avoid promising a specific date unless the team has made that commitment.&lt;/p&gt;

&lt;p&gt;An empty state can still produce broken internal links if a category card or product page points to an unqualified target. Test links from the hub, product detail, category page, and sitemap. A route that returns an error for an unknown or unqualified target may be correct, but it should not be linked as if it were ready.&lt;/p&gt;

&lt;p&gt;The recorded snapshot showed five named image cards prepared for Notion, Trello, Canva, Zapier, and Calendly, while no target comparison page was published. Those assets are not proof of completed editorial review. Keep design files, route records, and published content as separate states. A card can exist before the page qualifies, and that distinction should be clear to anyone reading the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Match structured data to visible content
&lt;/h2&gt;

&lt;p&gt;Structured data describes the page; it does not repair vague copy or guarantee a result in search. Use a type only when its definition fits. Questions and answers in markup should be visible to a reader. Product names, canonical URLs, and table values should agree across visible text and metadata.&lt;/p&gt;

&lt;p&gt;Recheck the output after the candidate set changes. A cached page can retain a former comparison after the live list no longer qualifies. Inspect a fresh build and the served response. Confirm status code, robots metadata, canonical, and sitemap membership together. Do not leave a self canonical on a removed route or point every potential page to the hub without a reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review the route as a visitor and a crawler
&lt;/h2&gt;

&lt;p&gt;Start at the hub and follow every link. Open each product and target route. Verify response codes, title, description, canonical, robots directives, and visible content. Check the sitemap for the hub and eligible targets. Test an unknown slug and a target that previously qualified but no longer does. Update links after removing a route.&lt;/p&gt;

&lt;p&gt;The initial HTML should contain the information a crawler needs without depending on a client side fetch. Check the social card and its alt text against the page that actually loads. A live response check verifies behavior at the time of inspection. It does not establish whether a search engine indexed the page or how it ranks.&lt;/p&gt;

&lt;p&gt;Use a simple editorial worksheet: target name, shared job, candidate products, evidence, live status, distinct count, review date, and decision. Then review whether the title implies endorsement, whether the introduction explains limits, whether fields are attributed, and whether the empty state offers a working next step. This small record makes the page easier to maintain than a claim that it is curated.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Define the shared user job before collecting candidates.&lt;/li&gt;
&lt;li&gt;[ ] Confirm each candidate is live, distinct, and supported by evidence.&lt;/li&gt;
&lt;li&gt;[ ] Attribute maker supplied copy and pricing labels.&lt;/li&gt;
&lt;li&gt;[ ] Keep the empty hub accurate and useful.&lt;/li&gt;
&lt;li&gt;[ ] Verify route status, canonical, robots, internal links, and sitemap.&lt;/li&gt;
&lt;li&gt;[ ] Ensure structured data matches visible page content.&lt;/li&gt;
&lt;li&gt;[ ] Record evidence dates and recheck after catalog changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Avoid implying endorsement or a complete review
&lt;/h2&gt;

&lt;p&gt;A page titled as an alternative list can sound like a verdict. Explain that the page is a directory of products whose public descriptions matched a defined job. Do not say that one is better, safer, faster, or cheaper unless the team has evidence and a published method for that claim. Keep editorial copy distinct from maker supplied text.&lt;/p&gt;

&lt;p&gt;If community signals affect ordering, label them carefully. Votes indicate recorded interest, not a universal preference. Comments contain individual reports, not a representative survey. A rating captures member responses, not an independent test. Explain the sort rule where a reader encounters it, and avoid converting a position in the list into a quality award.&lt;/p&gt;

&lt;p&gt;Give each product a route to its source page. Readers can inspect current terms and capabilities there. If a field is missing, state that it is not provided rather than filling the gap. If a product site is unavailable, pause the inclusion review and do not treat its stale listing as current evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat the snapshot as a dated observation
&lt;/h2&gt;

&lt;p&gt;The date beside a live check is part of the evidence. The hub response, target route responses, and card files are observations from October 28, 2026. They do not say what the routes return today. When this article is reused, repeat the checks and update the date before describing current behavior.&lt;/p&gt;

&lt;p&gt;Keep raw response notes or a small reproducible script where the team can find them. Store the requested URL, final URL after redirects, status code, content type, and retrieval date. Avoid collecting unrelated response data. A short audit trail helps distinguish a route that was never published from one that became unavailable later.&lt;/p&gt;

&lt;p&gt;Do not treat the existence of an image as proof that a route is complete. A share card can be prepared in advance, but its title, image, and description need to match a public page before release. Check the initial HTML and metadata on the actual URL. If a page is not eligible, avoid linking its card as if the comparison were live.&lt;/p&gt;

&lt;p&gt;The October 28 response count covers one inspected hub and five inspected tool routes. It is a snapshot for those addresses, not an error rate for the site or a statement about later responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the publication rule understandable
&lt;/h2&gt;

&lt;p&gt;A threshold is easiest to maintain when it is described in the same language as the user job. Explain that the route requires five live products that do the same kind of work, not merely five products with a shared tag. Put the rule near the empty state and in the internal review checklist. This makes a rejection easier to explain and helps another editor apply the same standard later.&lt;/p&gt;

&lt;p&gt;The implementation should fail closed for a target that has not been deliberately configured. A popular product name in a URL is not enough reason to generate a page. Keep the set of approved targets explicit, and make sure the rendering route checks both membership in that set and current product eligibility. An unknown slug should not inherit a generic page with a misleading heading.&lt;/p&gt;

&lt;p&gt;When the data source is unavailable, avoid treating the failure as a successful empty result. The page builder should preserve the distinction between no qualifying set and an unsuccessful lookup. Decide whether to show a temporary response, serve a recent cached page, or omit the route from the sitemap, then test that policy. The response should not claim that no alternatives exist if the underlying lookup failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed between the two checks
&lt;/h2&gt;

&lt;p&gt;The October 28 snapshot had one awkward detail. The hub said that no alternatives page was published, yet it was a normal index eligible URL listed in the sitemap. That is an empty listing page offered to search engines, which is the same thin content problem that category pages solve with a threshold.&lt;/p&gt;

&lt;p&gt;A change deployed on October 30 closed that gap. The hub metadata now asks for the published target list first. When the list loads and is empty, the page emits &lt;code&gt;noindex, follow&lt;/code&gt;. When the lookup fails, the page keeps its default metadata, so an API error does not quietly mark the hub noindex. The hub also stopped emitting an item list with zero entries in its structured data. The sitemap now adds the hub only when the first tool page is published.&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="c1"&gt;// frontend/src/app/alternatives/page.tsx, excerpt&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPublishedSaasAlternatives&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;baseMetadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;baseMetadata&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On October 31 the served hub carried &lt;code&gt;&amp;lt;meta name="robots" content="noindex, follow"/&amp;gt;&lt;/code&gt;, and the sitemap had no URL under &lt;code&gt;/alternatives&lt;/code&gt;. The visible sentence and the three questions were unchanged. Visitors still get the same page, with the same explanation of the rule. The only difference is that the site no longer asks search engines to index a directory with nothing in it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngu7ya1jx01ojmhd53ea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngu7ya1jx01ojmhd53ea.png" alt="The alternatives hub and five named tool routes on October 28 and October 31, 2026." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: RadarMakers route checks on October 28 and October 31, 2026, and sitemap.xml on both dates. Chart: RadarMakers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The target list in the code was still empty on October 31, so the five match rule had nothing to evaluate. That is the expected state for a rule that refuses to publish on a category match alone. The empty list is not a bug to fix by adding weaker targets. It is a record that no hand checked set has reached five live products yet.&lt;/p&gt;

&lt;p&gt;Two habits come out of this change. First, an empty state needs its own metadata decision, the same as a thin category. Second, the failure case needs a separate decision, written next to the code, so that a timeout does not flip a page into noindex or into the sitemap by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not prove
&lt;/h2&gt;

&lt;p&gt;An empty directory is not a review of Notion, Trello, Canva, Zapier, Calendly, or any other product. It does not say that RadarMakers has no products in those jobs. It says no hand-checked set of five live products was published for one well known tool. Search engines were told about the hub URL through IndexNow on 28 October 2026. That notice does not create tool pages, and it does not say the hub is indexed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RadarMakers alternatives implementation, frontend/src/lib/saas-alternatives.ts, route and template behavior recorded in this article.&lt;/li&gt;
&lt;li&gt;RadarMakers live response inspection recorded October 28, 2026. The hub returned HTTP 200 and the five named target routes returned HTTP 404.&lt;/li&gt;
&lt;li&gt;RadarMakers live recheck on October 31, 2026: hub response and robots meta tag, five named tool routes, seven image files, and sitemap.xml.&lt;/li&gt;
&lt;li&gt;RadarMakers alternatives hub metadata change deployed October 30, 2026, frontend/src/app/alternatives/page.tsx.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>startup</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Programmatic SEO pages and thin content: noindex until there are three items</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Sat, 31 Oct 2026 05:02:27 +0000</pubDate>
      <link>https://dev.to/radarmakers/programmatic-seo-pages-and-thin-content-noindex-until-there-are-three-items-3o8b</link>
      <guid>https://dev.to/radarmakers/programmatic-seo-pages-and-thin-content-noindex-until-there-are-three-items-3o8b</guid>
      <description>&lt;p&gt;Generating pages from your own data is the cheap way to give a site hundreds of URLs. It is also the cheap way to publish hundreds of pages that say almost nothing. We chose to treat listings with fewer than three live products as too small for index eligibility. This post shows the rule we use for our category and tag pages: when the lookups succeed, a page with fewer than three live products emits noindex metadata while it stays available to visitors. The examples come from &lt;a href="https://radarmakers.com/categories/developer-tools?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=programmatic-seo-pages-and-thin-content-noindex-until-there-are-three-items" rel="noopener noreferrer"&gt;RadarMakers&lt;/a&gt;, where categories like Developer Tools are built from the catalog.&lt;/p&gt;

&lt;p&gt;Three is our number. It is an application rule, not a search engine requirement and not a measured quality score. The rest of this post is about how to make such a rule behave well, including what it does when the data is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of generated pages
&lt;/h2&gt;

&lt;p&gt;Category pages live at &lt;code&gt;/categories/&amp;lt;slug&amp;gt;&lt;/code&gt;. Selected categories have editorial copy in &lt;code&gt;src/lib/category-pages.ts&lt;/code&gt;, followed by their live product listings.&lt;/p&gt;

&lt;p&gt;Tag pages live at &lt;code&gt;/tag/&amp;lt;slug&amp;gt;&lt;/code&gt;. Their text is assembled from facts about the products carrying the tag, using helpers such as &lt;code&gt;listFacts&lt;/code&gt;, &lt;code&gt;tagIntro&lt;/code&gt; and &lt;code&gt;tagFaq&lt;/code&gt; in &lt;code&gt;src/lib/tag-pages.ts&lt;/code&gt;. The helpers describe what the catalog returns at that moment and avoid quality claims.&lt;/p&gt;

&lt;p&gt;Both kinds apply the same threshold.&lt;/p&gt;

&lt;h2&gt;
  
  
  One gate, one definition of "live"
&lt;/h2&gt;

&lt;p&gt;The category threshold is a constant and a one-line function in the same file:&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="c1"&gt;// frontend/src/lib/category-pages.ts, excerpt&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_INDEXED_PRODUCTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// frontend/src/lib/category-pages.ts, excerpt&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;categoryIndexable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;liveCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;liveCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MIN_INDEXED_PRODUCTS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tag pages use a separate constant with the same value, &lt;code&gt;MIN_PRODUCTS = 3&lt;/code&gt;, from &lt;code&gt;src/lib/best-ranking.ts&lt;/code&gt;. The same file defines what counts as a product for this purpose:&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="c1"&gt;// frontend/src/lib/best-ranking.ts, excerpt&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;liveProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;Product&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;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;])).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For category and tag metadata, &lt;code&gt;liveProducts&lt;/code&gt; counts only products with the status &lt;code&gt;live&lt;/code&gt; and removes duplicate IDs, so a product that appears twice in the fetched data counts once. A product that is not &lt;code&gt;live&lt;/code&gt; never raises the number.&lt;/p&gt;

&lt;p&gt;Here is what that rule looked like against the live catalog on October 31, 2026. I counted distinct live product IDs for each of the 16 categories through the public products endpoint, then read the robots meta tag from each served category page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wgl9mkohpwx1p4vbzmp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wgl9mkohpwx1p4vbzmp.png" alt="Live products per category on October 31, 2026, with the threshold of three and the robots tag each page served." width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: radarmakers.com public products endpoint and served category HTML, checked October 31, 2026 at 04:55 UTC. Chart: RadarMakers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Seven categories had three or more live products and served no robots override. Nine had fewer than three and served &lt;code&gt;noindex, follow&lt;/code&gt;. Three of those nine had no live products at all. The two categories sitting exactly on the line, Apps and Other, both had three live products and both were index eligible. That is the boundary doing what the constant says.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the page emits
&lt;/h2&gt;

&lt;p&gt;The category layout builds its metadata and adds a robots field only when the page is below the threshold:&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="c1"&gt;// frontend/src/app/categories/[slug]/layout.tsx, excerpt&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;empty&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At or above the threshold the layout emits no robots field, so the default applies. The tag page is explicit in both directions:&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="c1"&gt;// frontend/src/app/tag/[slug]/page.tsx, excerpt&lt;/span&gt;
    &lt;span class="nx"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MIN_PRODUCTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;follow: true&lt;/code&gt; is deliberate. A thin page is still a normal page for people, and crawlers that support the directive are still allowed to follow its links to the product pages. The metadata asks search engines that support it not to index the page. It does not stop them from crawling it, and a crawler can only read the instruction if it is allowed to fetch the page, which is why these pages are not blocked in &lt;code&gt;robots.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When I checked the live site on 2026-10-28, the served HTML matched this. The Developer Tools category page had no robots meta tag, the Other, Web Games and Health category pages had &lt;code&gt;&amp;lt;meta name="robots" content="noindex, follow"/&amp;gt;&lt;/code&gt;, and a tag page such as &lt;code&gt;/tag/ai&lt;/code&gt; had &lt;code&gt;index, follow&lt;/code&gt;. Those are the pages as served on that day. Counts change as products are added, and this check says nothing about what any search engine did with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sitemap follows the same rule
&lt;/h2&gt;

&lt;p&gt;Our sitemap policy excludes listings below the same threshold used by their metadata. A URL that we mark noindex and also offer in the sitemap would send two opposite signals, and the comment in &lt;code&gt;sitemap.ts&lt;/code&gt; says the map must not offer such pages. The sitemap performs its own requests, so its inclusion decision is not derived from the metadata response.&lt;/p&gt;

&lt;p&gt;For categories, the sitemap code keeps only entries that pass &lt;code&gt;categoryIndexable&lt;/code&gt;. It also needs two more things before a URL goes in: the ranked list for the category must load, and the list must give a valid date to use as &lt;code&gt;lastmod&lt;/code&gt;. That date comes from a small helper:&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="c1"&gt;// frontend/src/lib/category-pages.ts, excerpt&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content_updated_at&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;launched_at&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes the latest valid date among the products, preferring a content update date, then the launch date, then the creation date. A category whose list cannot be loaded drops out of the sitemap rather than appearing with a guessed date.&lt;/p&gt;

&lt;p&gt;Tags need an eligible tag, at least three live matching products in the data the sitemap fetched itself, and a valid product date. Passing the count gate is necessary but not sufficient. On 2026-10-28 the live sitemap listed these five category URLs, and none of the three noindex category pages I fetched, Other, Web Games and Health, was among them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s https://radarmakers.com/sitemap.xml | grep -o "&amp;lt;loc&amp;gt;https://radarmakers.com/categories/[^&amp;lt;]*&amp;lt;/loc&amp;gt;"
&amp;lt;loc&amp;gt;https://radarmakers.com/categories/ai-tools&amp;lt;/loc&amp;gt;
&amp;lt;loc&amp;gt;https://radarmakers.com/categories/developer-tools&amp;lt;/loc&amp;gt;
&amp;lt;loc&amp;gt;https://radarmakers.com/categories/productivity&amp;lt;/loc&amp;gt;
&amp;lt;loc&amp;gt;https://radarmakers.com/categories/design&amp;lt;/loc&amp;gt;
&amp;lt;loc&amp;gt;https://radarmakers.com/categories/fintech&amp;lt;/loc&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three days later, the same check
&lt;/h2&gt;

&lt;p&gt;Catalogs move, so a dated check goes stale quickly. On October 31, 2026 I repeated the check across all 16 category pages and the sitemap. The sitemap now listed seven category URLs instead of five. The two new ones were Apps and Other. Other had been one of the noindex pages on October 28. By October 31 it had reached three live products, it served no robots override, and the sitemap listed it. Nothing in the code changed for that to happen. The catalog crossed the threshold and both signals followed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk834skou0ungwkdkebgc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk834skou0ungwkdkebgc.png" alt="Robots tag and sitemap presence for all 16 category pages on October 31, 2026." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: radarmakers.com/sitemap.xml and served category HTML, checked October 31, 2026 at 04:55 UTC. Chart: RadarMakers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The useful result is the empty cells. No category page served noindex while also appearing in the sitemap, and no index eligible category was missing from it. That agreement is what the shared threshold is supposed to produce. It is a snapshot of what the server sent at one time, and it still says nothing about what any search engine did with those pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the product lookup fails
&lt;/h2&gt;

&lt;p&gt;This is the part worth spelling out, because a rule that depends on a count has to say what happens when the count is unavailable. The category layout makes two lookups at the same time, the category itself and the live products of that category, each with a short timeout:&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="c1"&gt;// frontend/src/app/categories/[slug]/layout.tsx, excerpt&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;withinDeadline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getCategories&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;withinDeadline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getProducts&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MIN_INDEXED_PRODUCTS&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;categoryIndexable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;liveProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes follow from this code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the category is not found, or its lookup fails, the layout emits &lt;code&gt;index: false, follow: true&lt;/code&gt;. We deliberately emit noindex when category data is unavailable.&lt;/li&gt;
&lt;li&gt;If the category is found and the product lookup succeeds, the count decides. A lookup that resolves with &lt;code&gt;null&lt;/code&gt; is treated as an empty list, so it counts as zero products and gets noindex.&lt;/li&gt;
&lt;li&gt;If the category is found and the product lookup throws or times out, the catch handler returns &lt;code&gt;false&lt;/code&gt; for &lt;code&gt;empty&lt;/code&gt;. The layout then emits no robots field, and the page stays index eligible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third case is a policy, and the comment in the file states it: when the API fails, we do not guess. For a response whose category lookup succeeds but whose product lookup rejects or times out, the layout omits the robots override. This post does not establish how long any indexing effect of that would last. We chose to leave the page open in that case, instead of marking it noindex on the strength of a count we never got. Another policy would emit noindex whenever the count cannot be fetched, and a site with many thin pages and an unreliable API might prefer it. The point is to decide on purpose and write it down, not to inherit whatever an empty catch happens to do.&lt;/p&gt;

&lt;p&gt;This failure path covers category metadata only. It does not describe successful rendering of the page, and I am not describing tag page failure behavior here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renaming without losing the old URLs
&lt;/h2&gt;

&lt;p&gt;Category slugs were renamed in migration 050: &lt;code&gt;ai&lt;/code&gt; became &lt;code&gt;ai-tools&lt;/code&gt;, &lt;code&gt;devtools&lt;/code&gt; became &lt;code&gt;developer-tools&lt;/code&gt;, and &lt;code&gt;mobile&lt;/code&gt; became &lt;code&gt;apps&lt;/code&gt;. Products point to categories by ID, so the rename did not touch them. The old addresses are kept by permanent redirects built from one table in the code, &lt;code&gt;RENAMED_CATEGORY_SLUGS&lt;/code&gt;, for both &lt;code&gt;/categories/&amp;lt;old&amp;gt;&lt;/code&gt; and &lt;code&gt;/best/&amp;lt;old&amp;gt;&lt;/code&gt;. On the live site, &lt;code&gt;/categories/devtools&lt;/code&gt; answers with a 301 to &lt;code&gt;/categories/developer-tools&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not tell you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;"Index eligible" means the metadata allows indexing. It does not mean any page is indexed.&lt;/li&gt;
&lt;li&gt;Three products is not a threshold that search engines publish. It is where we drew the line for these pages.&lt;/li&gt;
&lt;li&gt;No traffic, ranking or indexing results are reported here. The checks above show what the server sends, not what happened afterwards.&lt;/li&gt;
&lt;li&gt;The count gate is only part of the sitemap rule. The data checks decide inclusion too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test boundary behavior, not only the happy case
&lt;/h2&gt;

&lt;p&gt;The threshold is meaningful only when the record definition is consistent. A list with two distinct live IDs should fail. A list with three should pass. Repeating one live ID must not make a two product category pass. Adding a pending or archived listing must not raise the live count. Put these cases in tests for both categories and tags because separate constants can drift even when they currently share a value.&lt;/p&gt;

&lt;p&gt;Do not count a client side display if server metadata uses another query. The visitor could see three cards while metadata sees two records after duplicate removal. The reverse could happen if metadata counts something not rendered. Keep the filtering rule visible and compare the page, metadata, and sitemap against the same source where practical.&lt;/p&gt;

&lt;p&gt;A successful empty lookup is different from a failed request. When a request times out, the code cannot honestly conclude that the category has zero products. Return distinct states for success with products, success with no products, and failure. The renderer can then show a useful empty state for the first case and a temporary error or retry path for the second. Metadata and sitemap policy should also be explicit for failure.&lt;/p&gt;

&lt;p&gt;Avoid catching every exception and replacing it with an empty array. That hides operational failure and may change robots metadata by accident. If you cache the last successful result, document its age limit and invalidation behavior. A retry can help with a transient problem, but excessive retries can increase load during an outage. Do not claim a recovery guarantee unless the service provides one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep metadata and sitemap checks together
&lt;/h2&gt;

&lt;p&gt;Robots metadata and sitemap generation are separate code paths. For the same category fixture, compare the live count, robots directive, canonical, response status, and sitemap decision. A deliberately noindex page should not also be included in the sitemap under this policy. An index eligible page can still be excluded if the data request fails or there is no valid last modified date.&lt;/p&gt;

&lt;p&gt;A sitemap is a discovery hint, not a promise of indexing. Google says it helps search engines learn which URLs a site considers important, but it does not guarantee that a URL appears in results. A noindex route may still be discoverable through links. Align signals, but do not describe a sitemap inspection as an indexing result.&lt;/p&gt;

&lt;p&gt;Test below, at, and above the boundary, plus duplicate IDs, no valid date, and request failure. Run the cases for categories and tags. Add a test that inspects generated metadata and a separate one for sitemap output. Parse generated output rather than relying only on a screenshot.&lt;/p&gt;

&lt;p&gt;Keep redirects in the same review. When a category slug changes, assert the old path resolves to the intended new path. Check redirect status, final URL, canonical, and internal links. A redirect map preserves old paths only when its destination is correct. Test the destination instead of merely checking that a redirect happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate page quality from index eligibility
&lt;/h2&gt;

&lt;p&gt;A mechanical count cannot decide whether the page answers a real query. Three unrelated products can satisfy a code condition and still make a weak comparison. A carefully edited page about a smaller category may answer a narrow need. Treat the threshold as a guardrail and review the content separately.&lt;/p&gt;

&lt;p&gt;Read each generated page as a visitor. Does the introduction describe the category without claiming unsupported features? Can someone compare products using facts that are actually present? Is there a useful next step? A heading followed by a handful of cards may be too little. Consider adding a genuine editorial explanation or keeping the route out of search until it helps the intended audience.&lt;/p&gt;

&lt;p&gt;Do not create interchangeable paragraphs by inserting a category name into a template. That makes URLs look different while saying the same thing. Use catalog facts only when they are accurate and useful. A missing price field does not prove that a product is free or paid. Avoid turning a blank feature field into a negative statement.&lt;/p&gt;

&lt;p&gt;As listings change, review routes whose state crosses the boundary. Record which categories changed and check metadata and sitemap output. A page becoming index eligible is a code state, not proof of traffic or ranking. Label the date of a live inspection and keep operational changes separate from visitor analytics.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Test two distinct live records, the threshold, and duplicate IDs.&lt;/li&gt;
&lt;li&gt;[ ] Handle successful empty results separately from failed requests.&lt;/li&gt;
&lt;li&gt;[ ] Inspect metadata and sitemap output with the same fixtures.&lt;/li&gt;
&lt;li&gt;[ ] Review whether the page answers a distinct visitor need.&lt;/li&gt;
&lt;li&gt;[ ] Test old slugs through to the final destination.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Preserve meaning when category routes are renamed
&lt;/h2&gt;

&lt;p&gt;A renamed slug affects more than a route lookup. Search metadata, sitemap entries, breadcrumbs, internal links, and cached social cards can each retain the earlier path. Keep one redirect map and test the old and new address together. A permanent redirect should lead to the final canonical path rather than another redirect chain. Update internal navigation to point directly to the current address even when the redirect remains for old links.&lt;/p&gt;

&lt;p&gt;Check that the destination still passes the index eligibility rule. A redirect to a category that is itself noindex may preserve access while not producing the intended search landing page. That is a policy choice worth recording. Do not keep a redirect merely because a prior slug existed if the destination no longer represents the same category.&lt;/p&gt;

&lt;p&gt;When a category is merged, preserve the old route only if the new route is a genuine replacement. Otherwise, an honest not found response can be better than redirecting every retired category to a broad page. The destination should match the previous visitor intent. Review the heading and introduction after the redirect, not only the status code.&lt;/p&gt;

&lt;p&gt;A deployment checklist can exercise the same URLs across HTML, sitemap, and redirects. Fetch the old route, follow redirects, inspect canonical metadata on the destination, then search the sitemap for the final path. This gives the team a repeatable record of how the policy behaved at release time. None of these checks alone proves that an external search engine has updated its index.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep decisions small and reversible
&lt;/h2&gt;

&lt;p&gt;When evidence is incomplete, avoid turning a failed data request into a permanent content judgment. A temporary API failure should not make the application claim a category has no products. Store the failed state, preserve the last good result if the product policy allows it, and make the result age visible to the team. The public page should not quietly present stale data as current.&lt;/p&gt;

&lt;p&gt;Review the threshold itself on a schedule, but change it only when a visitor need and the implementation evidence justify a change. A threshold set too low can expose pages with little distinct value. A threshold set too high can keep useful specialist pages out of search eligibility. The right value is a local tradeoff, not a universal SEO number.&lt;/p&gt;

&lt;p&gt;Write down who can change the rule and which tests must pass. Include the sitemap, metadata, failure behavior, and page copy. That makes a policy adjustment reviewable and prevents a constant change from leaving contradictory comments or an outdated article behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep the category and tag thresholds aligned. In this code they are two constants with the same value.&lt;/li&gt;
&lt;li&gt;Document what your metadata does when a lookup fails.&lt;/li&gt;
&lt;li&gt;Keep old URLs alive with permanent redirects when you rename slugs.&lt;/li&gt;
&lt;li&gt;Inspect the served HTML and the sitemap with &lt;code&gt;curl&lt;/code&gt; after you deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RadarMakers implementation snapshot checked October 28, 2026: frontend/src/lib/category-pages.ts, frontend/src/lib/best-ranking.ts, frontend/src/app/categories/[slug]/layout.tsx, frontend/src/app/tag/[slug]/page.tsx, and frontend/src/app/sitemap.ts.&lt;/li&gt;
&lt;li&gt;Google Search Central, Block Search Indexing with noindex: &lt;a href="https://developers.google.com/search/docs/crawling-indexing/block-indexing" rel="noopener noreferrer"&gt;https://developers.google.com/search/docs/crawling-indexing/block-indexing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;RadarMakers live category and sitemap checks described in this article, recorded October 28, 2026 and repeated October 31, 2026.&lt;/li&gt;
&lt;li&gt;RadarMakers public products endpoint, live product counts per category, checked October 31, 2026.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>seo</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>startup</category>
    </item>
    <item>
      <title>A Heading Count Is Only the Start of an Outline Review</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Fri, 30 Oct 2026 22:52:56 +0000</pubDate>
      <link>https://dev.to/radarmakers/a-heading-count-is-only-the-start-of-an-outline-review-43c7</link>
      <guid>https://dev.to/radarmakers/a-heading-count-is-only-the-start-of-an-outline-review-43c7</guid>
      <description>&lt;p&gt;A page can have one H1 and still be awkward to navigate. It can also have many lower level headings for a sensible reason. A useful heading audit records both the ranks and the words, then reads them in order.&lt;/p&gt;

&lt;p&gt;I inspected the initial HTML of five live RadarMakers routes on October 30, 2026, around 11:49 UTC. The &lt;a href="https://radarmakers.com/tools?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=a-heading-count-is-only-the-start-of-an-outline-review" rel="noopener noreferrer"&gt;tools collection&lt;/a&gt; has a deeper structure than the other sampled pages. That difference is visible in the data, but interpreting it requires reading the sections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the observed ranks
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;H1&lt;/th&gt;
&lt;th&gt;H2&lt;/th&gt;
&lt;th&gt;H3&lt;/th&gt;
&lt;th&gt;H4&lt;/th&gt;
&lt;th&gt;H5&lt;/th&gt;
&lt;th&gt;H6&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;/press&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/about&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/tools&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/launch-checklist&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/for-sale&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzipmsbohsuay4e32pki7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzipmsbohsuay4e32pki7.png" alt="H2, H3, and H4 counts for five production responses. Each also has one H1." width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: retained RadarMakers production responses captured October 30, 2026. Chart: RadarMakers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs0dmiilprle1lbqxpmo0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs0dmiilprle1lbqxpmo0.png" alt="Total heading elements and deepest observed rank in five retained RadarMakers responses." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: retained RadarMakers production responses captured October 30, 2026. Chart: RadarMakers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The tools response has 27 heading elements in total. Seventeen are H3 or H4. Every sampled route has exactly one H1. These are literal response counts, including shared footer headings. They are not counts of unique topics or a complete accessibility assessment.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.w3.org/WAI/tutorials/page-structure/headings/" rel="noopener noreferrer"&gt;W3C heading tutorial&lt;/a&gt; explains that headings organize content and that ranks should express the section hierarchy. A count alone does not establish whether a heading describes its section or whether a visitor can understand the resulting outline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extract the sequence as well as the histogram
&lt;/h2&gt;

&lt;p&gt;Save the HTML to page.html. This snippet prints the order and the counts from the same response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;

&lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;headings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h1,h2,h3,h4,h5,h6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;headings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;headings&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retained tools response begins with its H1, followed by H2 "Choose a tool". It then includes H3 group labels such as "Check a page" and "Plan a launch". Tool names under those groups use H4. Later, FAQ questions use H3 beneath the FAQ H2. Those relationships explain why the page has a deeper histogram.&lt;/p&gt;

&lt;p&gt;A linter that simply recommends fewer H4 elements would miss the design decision. The relevant question is whether each level describes an actual child section and whether the group labels help people choose a task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate shared navigation from page content
&lt;/h2&gt;

&lt;p&gt;The sale page has three H2 elements. All three belong to shared footer groups: "Explore", "Help", and "Legal". Its sale content is currently an empty state without an H2. Saying that it has three detailed content sections would therefore be wrong.&lt;/p&gt;

&lt;p&gt;This is why the capture keeps the heading text. The same count can describe a substantive article outline, a catalog grid, or shared site navigation. If the page has a main landmark, scope a second extraction to that landmark and compare the two views. If it does not, identify the intended content wrapper in source before assuming a broad selector represents the article.&lt;/p&gt;

&lt;p&gt;Do not silently delete the footer from the dataset. Keep the whole response inventory, then label the scoped interpretation. Someone repeating the audit should know why their total differs from a content-only count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review words before changing levels
&lt;/h2&gt;

&lt;p&gt;Read the sequence without the paragraphs between it. Can you tell what the page offers and which sections belong together? Does a heading describe a task, a category, or only a vague benefit? If two neighboring headings could be swapped without changing the meaning, they may need clearer wording.&lt;/p&gt;

&lt;p&gt;Next, inspect visual styling separately. A small heading can still be structurally important. A large line of marketing copy can still be a paragraph. Choose the element for its role, then style it. Making every card title an H2 just to obtain the same font size can flatten meaningful groups.&lt;/p&gt;

&lt;p&gt;For a repeated card component, inspect its callers. A fixed heading level may work on a collection page and create a jump when reused inside a nested section. Pass the intended level or use separate variants when that makes the hierarchy explicit. Do not introduce a new abstraction until the actual reuse requires it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the rendered result
&lt;/h2&gt;

&lt;p&gt;Initial HTML may include streamed or conditionally displayed content. The sequence here describes the retained response, not a completed browser accessibility tree. After any proposed change, inspect the page with its normal styles and with a heading navigation tool or screen reader.&lt;/p&gt;

&lt;p&gt;Check desktop and narrow layouts. Verify that hiding a decorative block does not leave a confusing heading in the accessible structure. Inspect the FAQ expanded and collapsed states. A heading inventory is a preparation step for those tasks, not a substitute for them.&lt;/p&gt;

&lt;p&gt;If the outline already expresses the sections clearly, keep it. A review should be able to conclude that a deeper structure is justified. Changing markup merely to produce a smaller chart would optimize the report instead of the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record a bounded result
&lt;/h2&gt;

&lt;p&gt;This sample establishes five single H1 responses and a tools outline that reaches H4. It also shows that footer headings can dominate a small page's count. It does not establish search performance or certify accessibility.&lt;/p&gt;

&lt;p&gt;The useful output is a sequence another person can inspect, a table they can reproduce, and a small set of questions tied to real sections. That is enough to turn a heading count into a practical review without pretending the count is the verdict.&lt;/p&gt;

&lt;p&gt;Source: RadarMakers HTTPS captures, October 30, 2026. Evidence includes response hashes, every observed heading label, and extraction data. No source code or production content was changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the totals chart beside the rank table
&lt;/h2&gt;

&lt;p&gt;The companion chart sums the heading counts for each retained route and labels the deepest rank observed. The tools response contains 27 heading elements and reaches H4. The launch checklist contains ten. The press, about, and sale responses contain eight, nine, and four. These totals include shared page regions because the capture counts the full response.&lt;/p&gt;

&lt;p&gt;The chart makes route size visible, not outline quality. A larger count can result from useful subgroups, repeated cards, a long FAQ, or site chrome. The sale response is a useful warning: its three H2 elements belong to the shared footer groups, while its sale content is an empty state without an H2. The total cannot tell that story unless the labels and page are read too.&lt;/p&gt;

&lt;p&gt;The deepest rank is also descriptive. The fact that a page reaches H4 does not establish that H4 is wrong. The fact that another route stops at H2 does not prove that its hierarchy is sound. The next step is to compare the ordered labels with the content and the component structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record enough context to repeat the inventory
&lt;/h2&gt;

&lt;p&gt;For each response, preserve the requested URL, final URL after redirects, status code, capture time, content type, and response hash. Store the parser version and extraction script with the data. Keep the complete heading sequence alongside the histogram so another reviewer can see whether a total came from page content or shared layout.&lt;/p&gt;

&lt;p&gt;If the page changes between captures, do not overwrite the earlier evidence. Save a new capture and label the comparison date. A route can change headings because content changed, a template changed, a feature flag changed, or a response was served to a different state. The record should make those possibilities visible instead of collapsing them into one count.&lt;/p&gt;

&lt;p&gt;Separate response capture from browser inspection. The initial HTML is a useful reproducible artifact, but it may not include content inserted after JavaScript runs. A rendered browser tree can differ because of hydration, disclosure controls, or conditional rendering. Name which artifact supports each statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope the outline to the right region
&lt;/h2&gt;

&lt;p&gt;A whole-document selector includes the header, main content, footer, and any hidden template fragments present in the response. That scope is useful when documenting all literal headings, but a reader asking about the article outline may need a second view limited to the main landmark or the known content container.&lt;/p&gt;

&lt;p&gt;Do not silently replace one scope with another. Keep a whole-response inventory, then identify the content region separately. Record the selector and the reason it represents the page content. If the page has no main landmark, inspect the template and explain which element was used. A scoped table should be labeled as scoped so its totals do not look directly comparable to a whole-page table.&lt;/p&gt;

&lt;p&gt;Hidden content requires attention. Some components keep collapsed panels in the document, while others insert them only after activation. A static response can count a heading that a visitor cannot currently see. A browser accessibility tree can expose a different set depending on state. Inspect the page in the state that matches the question, and record whether the disclosure was open or closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare label sequences, not only counts
&lt;/h2&gt;

&lt;p&gt;Two pages can have the same H1 and H2 totals while presenting very different structure. Preserve each heading rank and normalized text in document order. Then read the list without paragraph copy, as a visitor using heading navigation might encounter it. The list should convey the major topics and the relationships among nested sections.&lt;/p&gt;

&lt;p&gt;Normalization must not erase meaning. Removing extra whitespace is usually useful. Removing all punctuation or collapsing labels with similar words can merge distinct headings. Keep the original text in the evidence and create a normalized field only for comparison. If a heading is empty or hidden, include that fact instead of dropping it silently.&lt;/p&gt;

&lt;p&gt;A useful review asks whether each heading labels the content that follows, whether sibling sections use a consistent level, and whether a new section is actually nested under the preceding topic. Do not turn these questions into an automatic ban on skipped ranks without consulting the relevant guidance and the page context. A heading rank communicates structure, not visual size.&lt;/p&gt;

&lt;p&gt;For repeated card grids, decide whether each card title is truly a section heading. A page with a list of many tools may need a group heading and links with descriptive names, not a heading element for every small card title. Another page may use headings for each item because users navigate among substantive sections. The component should reflect the content role rather than a font style.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate semantic structure from visual styling
&lt;/h2&gt;

&lt;p&gt;A design system can make H2 and H3 look similar, or make an ordinary paragraph look large. That visual choice does not define the document outline. Choose the semantic element for the section relationship, then use CSS to create the desired appearance. This keeps visual hierarchy flexible without encoding it as an inaccurate heading rank.&lt;/p&gt;

&lt;p&gt;If a design system has a component named &lt;code&gt;Heading&lt;/code&gt;, inspect how it chooses the HTML element. A component that always emits H2 may work on a flat page and become misleading when nested inside another section. Where callers need different structure, pass an explicit semantic level or expose separate components whose purpose is clear. Avoid a level prop that allows impossible values without validation.&lt;/p&gt;

&lt;p&gt;Review the component with its actual call sites. A single rendered page cannot reveal how the shared component behaves in other contexts. Search the repository for its use, capture representative routes, and check the result after the component change. Keep the evidence tied to the exact pages and states tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test interactive and responsive states
&lt;/h2&gt;

&lt;p&gt;Inspect the outline after opening and closing accordions, menus, tabs, and other controls that change content visibility. Determine whether hidden content remains in the accessibility tree and whether that behavior matches the interaction. A count from initial HTML cannot answer every state question.&lt;/p&gt;

&lt;p&gt;Check narrow and wide layouts when responsive components differ. A mobile navigation menu can contain duplicated labels or heading-like text that does not appear in the desktop structure. A hidden duplicate can remain in the response. Avoid assuming that visually hidden means absent from assistive technology, or that a display rule changes the semantic rank.&lt;/p&gt;

&lt;p&gt;Use a keyboard and a heading navigation feature in a screen reader or browser tool to test the resulting experience. Note the start point, page state, and route. If a heading is missing from the navigation list, inspect whether it is a heading in the rendered DOM or only styled text. If an unexpected heading appears, inspect whether it is hidden, duplicated, or part of a shared region.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn findings into a focused change
&lt;/h2&gt;

&lt;p&gt;When the outline is confusing, identify the exact relationship that is unclear before changing markup. A vague section title may need clearer words. A card title may be a link rather than a heading. A nested topic may need a rank that reflects its parent section. A repeated footer label may be correct as a site region while irrelevant to the article outline.&lt;/p&gt;

&lt;p&gt;State the proposed change and the expected evidence. For example, name the route, state which heading moves into the main region, and describe what the ordered list should show afterward. Avoid goals such as reduce headings by a third or eliminate all H4 elements. Those targets optimize counts without explaining how a person benefits.&lt;/p&gt;

&lt;p&gt;After an edit, repeat the same capture and browser checks. Compare the ordered heading list, not just the aggregate numbers. Verify that the content text remains present, shared navigation still works, and the accessible name of interactive controls remains clear. A reduced count is not automatically an improvement.&lt;/p&gt;

&lt;p&gt;If no change is warranted, write that conclusion. A deeper outline can be correct when it reflects genuine groups and nested content. Recording the reason helps prevent a later cleanup from flattening the structure merely to make a chart look simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explain what this sample can and cannot say
&lt;/h2&gt;

&lt;p&gt;The retained evidence establishes the heading counts and labels for five public RadarMakers responses on October 30, 2026. It shows one H1 on each route, nine H2 and nine H3 and eight H4 elements on the tools route, and footer-only H2 elements on the sale page. The capture and extraction records let another reviewer reproduce those observations.&lt;/p&gt;

&lt;p&gt;It does not establish an accessibility conformance result, search ranking effect, visitor success rate, or user preference. The counts do not say whether an individual label is understandable without reading it. The static response may not represent every rendered state. Those limits should remain close to the result when it is shared.&lt;/p&gt;

&lt;p&gt;A bounded report is still useful. It gives a reviewer a place to start, a reproducible sequence to inspect, and a set of concrete questions for the page owner. The goal is not to produce a perfect score. It is to make the document structure easier to reason about and to verify any change against the actual experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the review repeatable for future edits
&lt;/h2&gt;

&lt;p&gt;Keep a small evidence bundle for each review: the retained response, a structured heading sequence, the extraction script, the rendered state notes, and the final decision. Give each capture a date and a content hash. When a route is checked again, create a new record rather than replacing the earlier response. This makes the change visible and prevents a reviewer from comparing numbers from different page versions without noticing.&lt;/p&gt;

&lt;p&gt;A second person should be able to reproduce the inventory without needing the original browser session. Document the requested route and whether redirects occurred. Note whether scripts ran, whether any controls were opened, and which selector defined the main content view. If the extraction process changed, save the parser version and explain the difference. A reproducible method is more useful than a screenshot of a histogram because it shows how the evidence was made.&lt;/p&gt;

&lt;p&gt;When a proposed heading change is reviewed, compare the ordered labels and ranks before and after. Then read the page and test the route using keyboard navigation. If the counts are unchanged but the labels are clearer, the content may still have improved. If the counts change but the sequence is less understandable, the numeric result may be a regression. Keep the decision tied to the structure people encounter, not the appearance of the chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoid turning a sample into a site-wide claim
&lt;/h2&gt;

&lt;p&gt;The five captured routes were selected to demonstrate an audit. They do not represent every route type, every user state, or every page served to every visitor. A site-wide statement would require an inventory of route templates and a sampling method that covers those templates. It would also need a plan for dynamic content and authenticated views if those matter to the question.&lt;/p&gt;

&lt;p&gt;When extending the sample, group routes by template or meaningful content type. Explain why each route was selected and preserve the same capture method. If a category page and a tool page use different heading components, they should not be assumed to have the same outline behavior. The reported result should say exactly which routes were inspected.&lt;/p&gt;

&lt;p&gt;This caution is practical, not ceremonial. A small sample can identify a concrete page worth reviewing. It cannot support a claim about all visitors or all content. Keeping the scope visible lets another developer use the result without overgeneralizing it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>html</category>
      <category>a11y</category>
      <category>beginners</category>
    </item>
    <item>
      <title>An Image Loading Attribute Is a Hint, Not a Performance Result</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Fri, 30 Oct 2026 22:52:16 +0000</pubDate>
      <link>https://dev.to/radarmakers/an-image-loading-attribute-is-a-hint-not-a-performance-result-1ile</link>
      <guid>https://dev.to/radarmakers/an-image-loading-attribute-is-a-hint-not-a-performance-result-1ile</guid>
      <description>&lt;p&gt;A performance audit can start with a cheap question: which image loading instructions did the server actually send? That question is narrower than asking whether a page is fast. Keeping the two separate makes the first script useful without turning it into an invented benchmark.&lt;/p&gt;

&lt;p&gt;I captured five public RadarMakers pages over HTTPS on October 30, 2026, at about 11:49 UTC. The sample includes the &lt;a href="https://radarmakers.com/tools?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=an-image-loading-attribute-is-a-hint-not-a-performance-result" rel="noopener noreferrer"&gt;free maker tools collection&lt;/a&gt;, a press kit, an about page, a launch checklist, and a sale catalog. This is a convenience sample from one site, not a representative survey of the web.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count the literal attributes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;loading lazy&lt;/th&gt;
&lt;th&gt;loading eager&lt;/th&gt;
&lt;th&gt;Attribute absent&lt;/th&gt;
&lt;th&gt;Total img elements&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;/press&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/about&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/tools&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/launch-checklist&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/for-sale&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxs71c0bacl5lhp8t1rza.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxs71c0bacl5lhp8t1rza.png" alt="Counts of lazy, explicit eager, and unspecified loading attributes across five captured routes." width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: retained RadarMakers production responses captured October 30, 2026. Chart: RadarMakers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpw9c3h3225oc545kfh3f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpw9c3h3225oc545kfh3f.png" alt="Route-level share of image elements with an explicit lazy hint, derived from five retained RadarMakers responses." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: retained RadarMakers production responses captured October 30, 2026. Chart: RadarMakers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Across this sample, 28 of 31 img elements have loading set to lazy. Three omit the attribute. None explicitly says eager. The three categories partition literal markup; they do not partition actual request start times.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://html.spec.whatwg.org/multipage/embedded-content.html#the-img-element" rel="noopener noreferrer"&gt;HTML image specification&lt;/a&gt; defines loading behavior. An omitted attribute has eager behavior by default, but this inventory deliberately keeps omitted and explicitly eager instructions separate. That distinction can reveal framework defaults or intentional source changes. Lazy loading also does not promise that an image will wait until a particular scroll position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the extraction reproducible
&lt;/h2&gt;

&lt;p&gt;Save a response as page.html, then run this Python snippet with Beautiful Soup installed. It uses the bytes on disk rather than fetching a changing page during analysis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;

&lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;img&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;loading&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lazy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eager&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unspecified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the retained launch checklist response, the result is lazy 6, unspecified 1, and total 7. The absent eager key means zero observed elements, not a missing measurement. If you expect malformed loading values, record them in a separate invalid category. The captured sample has none.&lt;/p&gt;

&lt;p&gt;A page with CSS backgrounds, canvas artwork, or images inserted after hydration needs additional inspection. This code counts img elements in one server response. It does not discover every visual resource the browser can display.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow the count with a placement review
&lt;/h2&gt;

&lt;p&gt;Look at the first screen in a real browser. Identify the image that supplies useful content immediately. Inspect whether its loading instruction fits that role. An image near the top of the source is not necessarily visible near the top of every viewport. Responsive styles and hidden theme variants can change the relationship.&lt;/p&gt;

&lt;p&gt;On the sale page, two hero image elements omit loading and two empty state images are lazy. That markup is consistent with separate light and dark variants. It does not establish that all four are visible or that four independent downloads occur. Check computed styles, preload behavior, and the request waterfall before making that claim.&lt;/p&gt;

&lt;p&gt;The press kit has sixteen lazy img elements. That is a starting point for checking the gallery, not a reason to mark every image as high priority. A gallery can contain many previews that a visitor never reaches. Prioritizing them all could create a different problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the next measurement
&lt;/h2&gt;

&lt;p&gt;If the suspected issue concerns the largest visible image, record the viewport, cache state, network setting, and browser version before comparing a change. Keep a trace and identify the resource involved. Compare equivalent runs rather than treating the count of lazy attributes as a speed score.&lt;/p&gt;

&lt;p&gt;If the suspected issue concerns missing content during scrolling, test that behavior directly. Scroll at an ordinary pace, use the keyboard, and check what happens under a slower connection. A markup count cannot tell you whether a placeholder reserves enough space or whether the image has useful alternative text.&lt;/p&gt;

&lt;p&gt;For a component change, compare the generated HTML before and after. Confirm that the intended image changed and unrelated gallery elements did not. Then repeat the browser task. The response inventory and browser evidence answer different questions, and both belong in the review record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the conclusion inside the evidence
&lt;/h2&gt;

&lt;p&gt;The measured result is 31 img elements across five responses, with 28 explicit lazy hints and three absent loading attributes. There is no measured loading delay, speed improvement, accessibility verdict, or ranking outcome in those numbers.&lt;/p&gt;

&lt;p&gt;That boundary is useful. It gives a developer a small repeatable inventory, a specific page to inspect, and a clear next observation. It also keeps a convenient script from producing a confident answer to a question it never measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the second chart as a route comparison
&lt;/h2&gt;

&lt;p&gt;The companion chart expresses the same five captures as a fraction of each route total. The press kit, about page, and tools collection show explicit lazy hints on every captured image element. The launch checklist has six lazy hints among seven images. The sale catalog has two among four. Those fractions are arithmetic from the table, not an additional browser observation.&lt;/p&gt;

&lt;p&gt;This view can make an inspection queue easier to choose, but it still cannot tell you which images matter most. A route with every image marked lazy could have an important first-screen image. A route with a lower fraction could have decorative images that are correctly deferred. Treat the ratio as a prompt to inspect the actual page, not a target to maximize.&lt;/p&gt;

&lt;p&gt;The denominator matters. The about page contains one image element in the captured HTML. Its ratio is therefore one of one, but a single image says little about a gallery page. Always display the fraction next to a percentage. That prevents the tiny sample from looking like a broad benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve the response before parsing it
&lt;/h2&gt;

&lt;p&gt;A reproducible inventory starts by saving the exact response bytes. Record the route, retrieval time, response status, content type, and a cryptographic hash. Keep the response in a research folder with the extraction code and the generated table. If the page changes later, a reviewer can tell whether a different count came from a new page or a parser change.&lt;/p&gt;

&lt;p&gt;Do not let a script silently fetch production while it computes a report. A changing route can produce different markup between runs, and a network error can be mistaken for an empty page. Separate capture from analysis. Capture once, inspect the status, store the body, then make the parser consume that file. A failed or non-HTML response should produce an explicit error rather than a table of zeros.&lt;/p&gt;

&lt;p&gt;When saving public page data, keep the retained material appropriate for the project and avoid recording secrets or private account information. These routes are public, but the same workflow may later be applied to authenticated pages. Make the scope of the capture clear before reusing the script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make parsing decisions visible
&lt;/h2&gt;

&lt;p&gt;A small parser can classify the literal attribute without pretending to know browser behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;

&lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;page.html&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;img&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;loading&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;absent&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;lazy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;eager&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
        &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This version distinguishes an absent attribute from an invalid value. An empty loading attribute is not silently rewritten into a valid state by the report. If the page contains unusual whitespace or an invalid value, preserve that raw value in a separate detail file and decide how the specification treats it before interpretation.&lt;/p&gt;

&lt;p&gt;For a larger audit, store one record per image with route, position in the document, raw loading value, source URL, alt text presence, and relevant responsive attributes. Avoid collecting more fields than the review needs. The purpose is to explain what the browser received and identify the image that deserves follow-up, not to build an image analytics warehouse.&lt;/p&gt;

&lt;p&gt;Keep the parser deterministic. Use the same HTML parser version, selector, normalization, and category names for every route. If one response was processed differently, note the exception. A stable process makes the observed difference easier to discuss with the person who owns the component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate source order from what a person sees
&lt;/h2&gt;

&lt;p&gt;HTML order is not visual position. Grid layout, absolute positioning, responsive CSS, hidden variants, and hydration can alter which image appears first in the viewport. A list of img elements does not contain the complete rendering context. It also does not prove that every image source was requested.&lt;/p&gt;

&lt;p&gt;Start with a browser inspection at the viewport where the issue was reported. Identify the visible image and its role. Check whether the image is decorative, content-bearing, a logo, or part of a gallery. Inspect the computed layout and confirm which responsive source is selected. If the page renders both light and dark assets in the HTML, compare the browser network panel and visibility state before counting downloads.&lt;/p&gt;

&lt;p&gt;Review the network waterfall when the question concerns request timing. Record the page, viewport, cache state, network profile, browser build, and the request you are evaluating. A controlled comparison can show whether a change moved a request or altered the rendered result. Do not turn one run into a general performance promise.&lt;/p&gt;

&lt;p&gt;If the question concerns a user who scrolls, test the scroll behavior. If the question concerns a slow connection, test the relevant connection profile. If the question concerns an image that shifts content, inspect its dimensions and layout reservation. Each question requires an observation that the markup count cannot supply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep priority decisions tied to purpose
&lt;/h2&gt;

&lt;p&gt;An image near the top of a page may be the main content or a secondary decoration. The decision to defer it should follow the page purpose and the actual rendered experience. Do not set every element to eager because it appears in the first part of the source. Do not set every element to lazy because the gallery is long.&lt;/p&gt;

&lt;p&gt;Before changing a component, list the intended behavior for each image class. A page logo, a product screenshot, an empty-state illustration, and a lower gallery preview may need different treatment. Confirm whether the image is already preloaded or referenced in another way. Duplicate instructions can make the HTML harder to understand without changing the request that the browser makes.&lt;/p&gt;

&lt;p&gt;After the edit, capture the same route again and compare literal markup. Then repeat the browser observation that motivated the change. The first comparison checks that the implementation changed as intended. The second checks whether the visible behavior changed. Report both findings separately.&lt;/p&gt;

&lt;p&gt;Do not claim that a lazy attribute caused a measured speed improvement unless the measurement actually isolates that change. A faster run can come from cache state, server timing, a different image, or another concurrent change. The inventory offers an implementation clue. A separate performance experiment must establish a performance result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat image accessibility as a different review
&lt;/h2&gt;

&lt;p&gt;Loading behavior and alternative text solve different problems. An image can load at the right time and still lack a useful text alternative. An empty alternative can be appropriate for a decorative image, while a functional image may need a concise description of its purpose. Do not use the presence of alt text as evidence that loading behavior is correct, or the reverse.&lt;/p&gt;

&lt;p&gt;Review the rendered content and the image role. Ask what information a person would lose if the image were unavailable. If the adjacent text already supplies the same content and the image is decorative, the alternative may be empty. If an image is a link or button, its accessible name should explain the action. Follow the HTML specification and accessibility guidance for the actual component rather than adding a generic description to every file.&lt;/p&gt;

&lt;p&gt;The data in this article contains no audit of alt text quality. It contains only image loading attributes and image element totals from retained responses. That distinction protects the report from promising an accessibility result it did not collect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a before and after record
&lt;/h2&gt;

&lt;p&gt;A useful change record has four parts: the original response inventory, the reason for review, the implementation decision, and the post-change evidence. Link the affected component and the capture artifacts. State which images were expected to change. Include a browser check when the change is intended to alter rendered behavior.&lt;/p&gt;

&lt;p&gt;If the counts do not change after a source edit, investigate whether the wrong page variant was edited or whether the production response is stale. If the counts change but the browser behavior does not, that can still be a valid result. The markup changed, while the observed request schedule did not. Preserve that finding instead of forcing the story into a performance win.&lt;/p&gt;

&lt;p&gt;For repeated page templates, test a representative route and then verify the other affected routes. Do not assume that all callers pass the same image role. A shared component can produce different results based on route data, theme state, or responsive source. The regression list should match those variations that actually exist in the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bound the conclusion
&lt;/h2&gt;

&lt;p&gt;The capture provides one dated view of five public routes. It shows 31 img elements, 28 explicit lazy hints, three absent loading attributes, and no explicit eager values. It also gives the route-level breakdown shown in both charts. The results can be reproduced from the retained HTML and parser, but they are not a sample of other websites.&lt;/p&gt;

&lt;p&gt;The evidence does not measure load time, largest contentful paint, user satisfaction, accessibility conformance, search traffic, or ranking. It does not show every CSS image or every request after client-side rendering. Those are separate research questions. A good audit names what it knows and what it has not measured.&lt;/p&gt;

&lt;p&gt;That restraint makes the inventory more actionable. A developer can inspect a specific route, locate a potentially important image, reproduce the markup count, and decide whether a browser trace is needed. A report that says only that there are many lazy attributes would not provide that path.&lt;/p&gt;

&lt;p&gt;Source: retained RadarMakers production responses, October 30, 2026. Full capture hashes and extraction data accompany the draft. Cover and charts are original editorial graphics based on that inventory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn the inventory into a handoff another developer can use
&lt;/h2&gt;

&lt;p&gt;A short handoff should identify the route, image element, observed attribute, source capture, and next check. Avoid writing only that a page has many lazy images. The person receiving the note needs to know whether the suspected element is a hero, a gallery preview, a product screenshot, or a decorative state. Include a locator that can be found in the response or browser inspection, but keep the report free of private data.&lt;/p&gt;

&lt;p&gt;State the question in a way that can be answered. If the concern is whether the first meaningful image is delayed, ask for a browser trace of that image under the relevant viewport and network conditions. If the concern is that a large gallery requests too many files, inspect which sources are actually requested and whether the browser defers them. If the concern is layout movement, examine the rendered box and reserved dimensions. A count of loading hints cannot resolve those questions by itself.&lt;/p&gt;

&lt;p&gt;Keep the requested change narrow. If a developer changes one component prop, name which routes should reflect it and what the response inventory should show. If a route-level template is shared, identify another route that exercises a different image role. The regression sample should cover the component behavior that matters, not every URL on the site by default.&lt;/p&gt;

&lt;p&gt;After the handoff, record what was tested and what was not. A change can pass the markup check and still require a browser observation. A trace can show no request difference even though the attribute changed. Those are useful findings when they are reported accurately. Do not convert an implementation change into an unmeasured speed claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recheck when the content model changes
&lt;/h2&gt;

&lt;p&gt;An image collection can change shape when a product adds alternate themes, responsive variants, thumbnails, or a new empty state. The old inventory remains a valid record of its capture, but it should not be treated as a permanent description of the route. Refresh the capture when the template or content model changes in a way that affects img elements.&lt;/p&gt;

&lt;p&gt;If you automate the review, schedule it around a meaningful code change or a review event that the team can maintain. A daily count without a response to differences can create noise. A check attached to a component change can show exactly which route changed. Store the output where a reviewer can compare it with the source diff and the browser result.&lt;/p&gt;

&lt;p&gt;Do not make the automated check fail merely because the number changes. A new product image may be an intentional content update. Alert on a condition tied to a clear expectation, such as an unexpected missing image source or a disallowed attribute value, and have a person review the page context. Inventory should make deliberate changes visible, not prevent all change.&lt;/p&gt;

&lt;p&gt;The source captures here are intentionally small. Five pages are enough to explain a reproducible method, but they do not define a site-wide policy for every image. Before generalizing, list the remaining route templates and choose a sample that covers their actual image roles. Report that scope openly and keep the route observations separate from conclusions about performance.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>html</category>
      <category>performance</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Letting crawlers read noindex on Next.js auth pages</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Fri, 30 Oct 2026 14:32:27 +0000</pubDate>
      <link>https://dev.to/radarmakers/letting-crawlers-read-noindex-on-nextjs-auth-pages-157a</link>
      <guid>https://dev.to/radarmakers/letting-crawlers-read-noindex-on-nextjs-auth-pages-157a</guid>
      <description>&lt;p&gt;Sign-in pages are the classic thing to hide from search. The reflex is to put them in &lt;code&gt;robots.txt&lt;/code&gt; and move on. On &lt;a href="https://radarmakers.com?ref=devto" rel="noopener noreferrer"&gt;RadarMakers&lt;/a&gt; we did exactly that, and then changed our minds about one path. This post shows the two small edits, the reasoning behind them, and, just as important, what the change does not prove.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that is easy to get backwards
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;robots.txt&lt;/code&gt; and &lt;code&gt;noindex&lt;/code&gt; answer different questions. &lt;code&gt;robots.txt&lt;/code&gt; says whether a crawler may fetch a URL. &lt;code&gt;noindex&lt;/code&gt; says whether a page that was fetched may appear in search results. The second one can only be read from a response, so a crawler that is not allowed to fetch the URL never sees it.&lt;/p&gt;

&lt;p&gt;Google's documentation on &lt;a href="https://developers.google.com/search/docs/crawling-indexing/block-indexing" rel="noopener noreferrer"&gt;blocking indexing with noindex&lt;/a&gt; says this directly: for a noindex rule to be effective, the page must not be blocked by &lt;code&gt;robots.txt&lt;/code&gt;. If it is blocked, the crawler never sees the rule, and the URL can still show up in search, for example when other pages link to it.&lt;/p&gt;

&lt;p&gt;That makes a &lt;code&gt;Disallow&lt;/code&gt; line a poor tool for removing a URL that search engines already know about. It stops the visit that would have delivered the instruction to leave.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we had
&lt;/h2&gt;

&lt;p&gt;Our frontend has a sign-in flow, and the OAuth callback lives at &lt;code&gt;/auth/callback&lt;/code&gt;. It is a client component that reads the query string and either sends the person on or shows an error. A URL such as &lt;code&gt;/auth/callback?error=...&lt;/code&gt; is the kind of address that appears when a sign-in does not complete.&lt;/p&gt;

&lt;p&gt;Before the change, &lt;code&gt;src/app/robots.ts&lt;/code&gt; included &lt;code&gt;/auth/&lt;/code&gt; in its disallow list:&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="c1"&gt;// frontend/src/app/robots.ts, excerpt, before commit 46f72d7&lt;/span&gt;
      &lt;span class="nx"&gt;disallow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/dashboard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/auth/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/reset-password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/forgot-password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commit message and code comment name &lt;code&gt;/auth/callback?error=&lt;/code&gt; style URLs as the reason for the change. This post does not include crawl data about them, and it makes no claim about whether or when any of them left an index.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change
&lt;/h2&gt;

&lt;p&gt;One commit, &lt;code&gt;46f72d7&lt;/code&gt;, made two edits that only make sense together.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;"/auth/"&lt;/code&gt; came out of the disallow list. Nothing else in the list moved:&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="c1"&gt;// frontend/src/app/robots.ts, excerpt, current&lt;/span&gt;
      &lt;span class="nx"&gt;disallow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/dashboard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/reset-password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/forgot-password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, &lt;code&gt;next.config.ts&lt;/code&gt; got a &lt;code&gt;headers()&lt;/code&gt; function that adds &lt;code&gt;X-Robots-Tag: noindex&lt;/code&gt; to responses handled by Next.js for paths matching &lt;code&gt;/auth/:path*&lt;/code&gt;, which includes &lt;code&gt;/auth&lt;/code&gt; and &lt;code&gt;/auth/callback&lt;/code&gt;:&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="c1"&gt;// frontend/next.config.ts, excerpt&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;headers&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;[{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/auth/:path*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Robots-Tag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;noindex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3l45n3grf7wwt9zhzqmq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3l45n3grf7wwt9zhzqmq.png" alt="Before, auth crawling is blocked. After, crawlers can read the noindex header." width="800" height="576"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The diagram shows the intended behavior for a crawler that follows the rules. In the first panel the crawl is blocked, so the response is never fetched. In the second the crawl is allowed, the response carries the header, and the directive can be read. Removing the block and adding the header happen in the same commit, so the first panel is not claiming that a noindex header already existed behind the block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a header and not a meta tag
&lt;/h2&gt;

&lt;p&gt;A robots meta tag in the page &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; would also work for an HTML page. We chose a response header for these reasons, and they are design choices rather than requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One entry in &lt;code&gt;next.config.ts&lt;/code&gt; covers everything under the path. In the Next.js route syntax &lt;code&gt;:path*&lt;/code&gt; matches zero or more path segments, so &lt;code&gt;/auth/callback&lt;/code&gt; is covered, and so is any page we add under &lt;code&gt;/auth/&lt;/code&gt; later, without touching that page.&lt;/li&gt;
&lt;li&gt;The rule lives next to the other routing configuration, not scattered across page files.&lt;/li&gt;
&lt;li&gt;The callback is a client component, and we preferred not to build a separate server layout only to carry a robots tag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google documents &lt;code&gt;X-Robots-Tag&lt;/code&gt; as the way to send the same directive in an HTTP response, and it applies to responses that have no HTML head at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr726jjwoz01lddgl2g9h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr726jjwoz01lddgl2g9h.png" alt="The auth crawl block is removed and an auth noindex header is added. Other blocks remain." width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we left alone
&lt;/h2&gt;

&lt;p&gt;The other disallow entries are still in &lt;code&gt;robots.txt&lt;/code&gt;: &lt;code&gt;/admin&lt;/code&gt;, &lt;code&gt;/dashboard&lt;/code&gt;, &lt;code&gt;/api/&lt;/code&gt;, &lt;code&gt;/reset-password&lt;/code&gt; and &lt;code&gt;/forgot-password&lt;/code&gt;. This commit leaves them unchanged. The comment above that list mentions authentication and crawl budget, but it does not establish that every listed route requires a signed-in session, and this post does not evaluate those rules.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;/auth/&lt;/code&gt; the commit did one specific thing: because the goal was for the directive to be readable, the crawl block went away for that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost we accepted
&lt;/h2&gt;

&lt;p&gt;Allowing the crawl means a crawler can fetch &lt;code&gt;/auth/&lt;/code&gt; URLs again. The comment in the file gave crawl budget, meaning the resources a search engine spends crawling a site, as a reason to block paths like these. We decided that, for this one path, letting the directive be read is worth more than the goal of reducing crawler requests. That is a judgment call for a small site, not a rule. If your auth area is large, or your crawl budget is a real constraint, weigh it differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking what the server sends
&lt;/h2&gt;

&lt;p&gt;It is worth looking at the actual responses instead of trusting the config file. When I checked on 2026-10-28, &lt;code&gt;robots.txt&lt;/code&gt; on the live site no longer listed &lt;code&gt;/auth/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s https://radarmakers.com/robots.txt
User-Agent: *
Allow: /
Disallow: /admin
Disallow: /dashboard
Disallow: /api/
Disallow: /reset-password
Disallow: /forgot-password

Sitemap: https://radarmakers.com/sitemap.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a callback URL with an error parameter answered with the header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -sI "https://radarmakers.com/auth/callback?error=access_denied" | grep -i x-robots-tag
x-robots-tag: noindex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is all this check shows: the server is configured the way the commit intended. It says nothing about what any search engine has done with those URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not tell you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It is not a measured result. This post describes a configuration and the reasoning for it. It does not report indexing changes, timing or traffic.&lt;/li&gt;
&lt;li&gt;Search engines decide for themselves when to fetch a page again and what to do with it. A crawler that never returns will never see the header.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;noindex&lt;/code&gt; is a request to compliant crawlers. It is not access control. Keep the real protection on the server, which is what the authentication is for.&lt;/li&gt;
&lt;li&gt;I describe the behavior Google documents. Other search engines publish their own rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;Disallow&lt;/code&gt; stops the crawl, and a crawl is the only way a crawler can read &lt;code&gt;noindex&lt;/code&gt;. Do not use both on the same URL when you want the page out of an index.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;Disallow&lt;/code&gt; to restrict crawling. If the goal is exclusion from search through &lt;code&gt;noindex&lt;/code&gt;, let crawlers that support it fetch the directive, whether the URL is new or already known.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;X-Robots-Tag&lt;/code&gt; header in &lt;code&gt;next.config.ts&lt;/code&gt; covers a whole path with one entry, including pages you add later.&lt;/li&gt;
&lt;li&gt;Check the real response with &lt;code&gt;curl -I&lt;/code&gt; after you deploy.&lt;/li&gt;
&lt;li&gt;Say what you changed and why. Leave out results you have not measured.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>seo</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>IndexNow in Go: tell search engines about new pages in one request</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Fri, 30 Oct 2026 07:39:13 +0000</pubDate>
      <link>https://dev.to/radarmakers/indexnow-in-go-tell-search-engines-about-new-pages-in-one-request-329a</link>
      <guid>https://dev.to/radarmakers/indexnow-in-go-tell-search-engines-about-new-pages-in-one-request-329a</guid>
      <description>&lt;p&gt;When a maker's product goes live on &lt;a href="https://radarmakers.com?ref=devto" rel="noopener noreferrer"&gt;RadarMakers&lt;/a&gt;, we want search engines to know about the new page without waiting for a crawler to find it. IndexNow is a small protocol for exactly that: you send the URLs that changed, and the participating search engines are told about them. This post shows our Go client, the key file it needs, and what the protocol actually promises.&lt;/p&gt;

&lt;h2&gt;
  
  
  What IndexNow asks for
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.indexnow.org/documentation" rel="noopener noreferrer"&gt;IndexNow documentation&lt;/a&gt; describes two things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A key you can prove you own.&lt;/strong&gt; The key is 8 to 128 characters of letters, numbers and dashes. You host it in a UTF-8 text file, by default named after the key and placed at the root of the site, for example &lt;code&gt;https://example.com/your-key.txt&lt;/code&gt;, and the file contains the key. The documentation strongly recommends the root location. A file elsewhere on the host has to be named in each request with &lt;code&gt;keyLocation&lt;/code&gt;, and then only covers URLs under its own folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A request listing the changed URLs.&lt;/strong&gt; A single URL can go in a GET query string, but several URLs go in one JSON POST with the &lt;code&gt;host&lt;/code&gt;, the &lt;code&gt;key&lt;/code&gt;, an optional &lt;code&gt;keyLocation&lt;/code&gt; and a &lt;code&gt;urlList&lt;/code&gt; of up to 10,000 URLs.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.indexnow.org/faq" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt; lists a global endpoint, &lt;code&gt;https://api.indexnow.org/indexnow&lt;/code&gt;, next to the endpoints of individual search engines, and says you may submit to only one of them, because a submission is shared with all IndexNow enabled search engines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/indexnow/client.go, excerpt&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;validKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;regexp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`^[a-zA-Z0-9-]{8,128}$`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// ... Client declaration omitted&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;siteURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;siteURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scheme&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"https"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scheme&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;validKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MatchString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"indexnow: invalid public site URL or key format; disabled"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scheme&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"://"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://api.indexnow.org/indexnow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a key, or with a key or site URL that does not look right, &lt;code&gt;New&lt;/code&gt; returns &lt;code&gt;nil&lt;/code&gt;. Both methods handle a nil receiver, so the rest of the code calls the client the same way whether IndexNow is configured or not. A development machine without a key simply sends nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Submit&lt;/code&gt; builds the payload. It only accepts local absolute paths and turns them into full URLs on our own origin, so nothing else can end up in the list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/indexnow/client.go, excerpt&lt;/span&gt;
&lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"//"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&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="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"expected a local absolute path"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Host&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`json:"host"`&lt;/span&gt;
    &lt;span class="n"&gt;Key&lt;/span&gt;         &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`json:"key"`&lt;/span&gt;
    &lt;span class="n"&gt;KeyLocation&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`json:"keyLocation"`&lt;/span&gt;
    &lt;span class="n"&gt;URLList&lt;/span&gt;     &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"urlList"`&lt;/span&gt;
&lt;span class="p"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;".txt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request goes out as &lt;code&gt;POST&lt;/code&gt; with &lt;code&gt;Content-Type: application/json; charset=utf-8&lt;/code&gt;, the header the documentation shows. A response of 200 or 202 counts as success; anything else is an error.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpimgtpxams5fi45d0csu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpimgtpxams5fi45d0csu.png" alt="Payload and hosted key: one JSON payload with host, key, keyLocation and urlList, and the frontend key response at /test-key123.txt with the key as a text/plain body and Cache-Control no-store; example values from the client test" width="680" height="886"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fire and forget, on purpose
&lt;/h2&gt;

&lt;p&gt;The repositories call &lt;code&gt;Notify&lt;/code&gt;, not &lt;code&gt;Submit&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/indexnow/client.go, comment omitted&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&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;}&lt;/span&gt;
    &lt;span class="n"&gt;paths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"indexnow: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three choices are packed into those lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It runs in its own goroutine&lt;/strong&gt;, so publishing a product never waits for a search engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It uses a fresh context with a 5-second timeout&lt;/strong&gt;, not the request's context, so the ping does not depend on the publishing request being cancelled or finished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It logs failures and does not retry.&lt;/strong&gt; The sitemap is another way for search engines to find the page, and a later qualifying event, such as a product being set live again, sends another submission. We chose not to add a retry queue for this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The paths slice is copied before the goroutine starts, so a caller that reuses its slice cannot change what gets sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it after a successful write
&lt;/h2&gt;

&lt;p&gt;The product repository calls &lt;code&gt;Notify&lt;/code&gt; only after the database write succeeded and only when the product is live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/repository/product.go, excerpt&lt;/span&gt;
&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRowContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="s"&gt;" RETURNING slug"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusLive&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexNow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/product/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PathEscape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern covers a product created as live and a product whose status is set back to live. Community posts do it when a post ends up published after it is created, updated or approved. Each event sends its own single URL; the client can take several paths in one call, but we do not collect separate events into a batch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fupaf9wee9cerg6dx98gv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fupaf9wee9cerg6dx98gv.png" alt="From publication to ping: after a successful write a product or post triggers Notify, which runs Submit in a background goroutine and POSTs JSON to api.indexnow.org; HTTP 200 or 202 counts as accepted, other results are logged without retry" width="680" height="860"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving the key file from Next.js
&lt;/h2&gt;

&lt;p&gt;The site itself is a Next.js app, and the key is only known at runtime, from an environment variable. The file is answered by &lt;code&gt;src/proxy.ts&lt;/code&gt;, whose matcher only covers top-level &lt;code&gt;.txt&lt;/code&gt; paths:&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="c1"&gt;// src/proxy.ts, imports omitted&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isIndexNowKeyPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INDEXNOW_KEY&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;indexNowKeyResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INDEXNOW_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/:file([^/]+&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.txt)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/indexnow-key.ts, comment omitted&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isIndexNowKeyPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9-&lt;/span&gt;&lt;span class="se"&gt;]{8,128}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.txt`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;indexNowKeyResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isIndexNowKeyPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/plain; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the exact path for the configured key answers, with the key as plain text. Any other &lt;code&gt;.txt&lt;/code&gt; path passes through to the rest of the app. We checked the live site: the key path answers 200 with &lt;code&gt;text/plain; charset=utf-8&lt;/code&gt;, &lt;code&gt;no-store&lt;/code&gt; and a body equal to the key, and a made-up key path answers 404.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;The client tests point the endpoint at a local &lt;code&gt;httptest&lt;/code&gt; server. One test calls &lt;code&gt;Notify&lt;/code&gt; with two paths and checks the exact JSON that arrives, including the &lt;code&gt;keyLocation&lt;/code&gt; and both full URLs. Another makes the server answer 429 and checks that &lt;code&gt;Submit&lt;/code&gt; returns an error, checks that a cancelled context fails, and checks that &lt;code&gt;https://elsewhere.test&lt;/code&gt;, &lt;code&gt;//elsewhere.test&lt;/code&gt; and &lt;code&gt;/\elsewhere.test&lt;/code&gt; are refused as paths. A third checks that a client created without a key, or with a key that is too short or contains a slash, is nil, and that calling &lt;code&gt;Notify&lt;/code&gt; and &lt;code&gt;Submit&lt;/code&gt; on it is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not promise
&lt;/h2&gt;

&lt;p&gt;The IndexNow FAQ is direct about this. A 200 only means the search engine received the URLs; the first request may get a 202 while the key is verified. Submitting a URL does not guarantee that it is indexed. Each search engine decides for itself, and every submitted URL counts toward the site's crawl quota. The FAQ also says IndexNow is meant for recently added, updated or deleted URLs, recommends using it together with a sitemap, and advises against resubmitting unchanged URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Validate the key and the site URL once, and return a nil client when IndexNow is not configured, so callers never need a feature flag.&lt;/li&gt;
&lt;li&gt;Notify after the database write succeeds, from a goroutine with its own short timeout.&lt;/li&gt;
&lt;li&gt;Accept only local paths and build full URLs on your own origin.&lt;/li&gt;
&lt;li&gt;Serve the key file at the root with the key as plain text, and nothing else on that route.&lt;/li&gt;
&lt;li&gt;Keep your sitemap. IndexNow is a hint about what changed, not a replacement.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>go</category>
      <category>seo</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Blocking SSRF in Go when your server fetches URLs that users type</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:00:20 +0000</pubDate>
      <link>https://dev.to/radarmakers/blocking-ssrf-in-go-when-your-server-fetches-urls-that-users-type-f2g</link>
      <guid>https://dev.to/radarmakers/blocking-ssrf-in-go-when-your-server-fetches-urls-that-users-type-f2g</guid>
      <description>&lt;p&gt;Two features on our site fetch addresses that strangers type in. When a maker submits a product, the server fetches the product's website to find a logo. And the &lt;a href="https://radarmakers.com/tools/meta-tag-preview?ref=devto" rel="noopener noreferrer"&gt;meta tag and social card checker&lt;/a&gt; fetches any public page you give it and shows what a link preview would contain.&lt;/p&gt;

&lt;p&gt;A server that fetches arbitrary URLs is a classic server side request forgery risk. The server can reach things nobody else can: the API on localhost, a database container on the internal network, or a cloud metadata service on &lt;code&gt;169.254.169.254&lt;/code&gt;. If a user can point the fetcher there, the server becomes their proxy.&lt;/p&gt;

&lt;p&gt;This post shows the Go client we use for this, and why the check sits inside the dialer instead of before the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious version and its gap
&lt;/h2&gt;

&lt;p&gt;The first idea is to look up the hostname, check that every address it resolves to is public, and then make the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LookupIP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// ... reject if any ip is not public&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is that on a new direct connection, the client resolves the hostname again, independently of the check. If it receives a different answer, it can connect to an address the check never approved. A hostname with a very short TTL can answer the check with a public address and the connection's lookup with &lt;code&gt;127.0.0.1&lt;/code&gt;. This is known as DNS rebinding, and a check before the request cannot see it, because by the time the client connects, the answer that was checked is no longer the one being used.&lt;/p&gt;

&lt;p&gt;Redirects make it worse. A perfectly public page can answer with a redirect to &lt;code&gt;http://127.0.0.1/&lt;/code&gt;, and a default client follows it without asking anyone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figur5wmj8v0i17osjdpk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figur5wmj8v0i17osjdpk.png" alt="A DNS precheck leaves a gap: the precheck sees 93.184.216.34 and passes, the dial resolves again to 127.0.0.1 and nothing stops it; with a dial-time check, Control sees 127.0.0.1 and returns an error before connecting" width="680" height="846"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the address the connection uses
&lt;/h2&gt;

&lt;p&gt;Go's &lt;code&gt;net.Dialer&lt;/code&gt; has a &lt;code&gt;Control&lt;/code&gt; field. Its documentation says that if &lt;code&gt;Control&lt;/code&gt; is not nil, it is called after creating the network connection but before actually dialing. By then the hostname has been resolved, and the &lt;code&gt;address&lt;/code&gt; argument holds the IP and port of this particular connection attempt. That is exactly the value we want to check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/audit/ssrf.go, comments omitted&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;NewPublicOnlyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxRedirects&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dialer&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dialer&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Control&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;network&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawConn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SplitHostPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bad address"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseIP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isPublic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&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="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"address is not public"&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="no"&gt;nil&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Transport&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transport&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DialContext&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dialer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DialContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Proxy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DisableKeepAlives&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;CheckRedirect&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;via&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;via&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxRedirects&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"too many redirects"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scheme&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"http"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scheme&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"https"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"redirect to a non-web address"&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="no"&gt;nil&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details carry the weight here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anything that is not an IP is refused.&lt;/strong&gt; If &lt;code&gt;address&lt;/code&gt; does not parse as an IP, &lt;code&gt;net.ParseIP&lt;/code&gt; returns nil and the dial fails. The check never has to trust a name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every connection attempt is checked.&lt;/strong&gt; If a hostname has several addresses and the dialer tries more than one, each attempt calls &lt;code&gt;Control&lt;/code&gt;. A redirect opens a new connection, so it goes through &lt;code&gt;Control&lt;/code&gt; as well, whatever DNS says about the new host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Proxy: nil&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;http.Transport&lt;/code&gt; documentation says that if &lt;code&gt;Proxy&lt;/code&gt; is nil, no proxy is used. Go's default transport reads proxy settings from the environment. With a proxy configured, the dialer would connect to the proxy, and &lt;code&gt;Control&lt;/code&gt; would only ever see the proxy's address, not the target's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DisableKeepAlives: true&lt;/code&gt;.&lt;/strong&gt; Connections are not reused between requests, so each request opens its own connection and passes through &lt;code&gt;Control&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo7je9btbhcprrbstx8e9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo7je9btbhcprrbstx8e9.png" alt="Check the IP before connect: a user-supplied URL is resolved in the dialer, net.Dialer.Control splits host and port, parses the IP and runs isPublic; an allowed IP proceeds, a blocked or invalid one returns an error; blocked: loopback and private, unspecified, link-local unicast and all multicast, 100.64.0.0/10 and 0.0.0.0/8" width="680" height="1104"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which addresses count as public
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/audit/ssrf.go, comments omitted&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;isPublic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsLoopback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsPrivate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsUnspecified&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsLinkLocalUnicast&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsLinkLocalMulticast&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsInterfaceLocalMulticast&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsMulticast&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="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v4&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;To4&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;v4&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;64&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;127&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&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="no"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;net.IP&lt;/code&gt; helpers cover loopback, the private ranges &lt;code&gt;10.0.0.0/8&lt;/code&gt;, &lt;code&gt;172.16.0.0/12&lt;/code&gt;, &lt;code&gt;192.168.0.0/16&lt;/code&gt; and &lt;code&gt;fc00::/7&lt;/code&gt;, link-local addresses, which include the metadata address &lt;code&gt;169.254.169.254&lt;/code&gt;, and multicast. The two extra checks block carrier-grade NAT, &lt;code&gt;100.64.0.0/10&lt;/code&gt;, and &lt;code&gt;0.0.0.0/8&lt;/code&gt;, which have no helper.&lt;/p&gt;

&lt;p&gt;Be honest with yourself about what this is: a deny list of the ranges that matter for our server. The Go documentation for &lt;code&gt;IsPrivate&lt;/code&gt; itself says it "does not describe a security property of addresses, and should not be used for access control". There are special purpose ranges this function does not list. If your network has other internal ranges, or translates addresses between IPv6 and IPv4, add them or, better, also block outgoing connections to internal networks at the firewall, so the code is not the only line of defense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redirects that you follow yourself
&lt;/h2&gt;

&lt;p&gt;The logo fetcher lets the client follow redirects, up to its limit. The meta tag checker wants to show every redirect step to the user, so it turns automatic redirects off and follows them by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/inspect/inspect.go, excerpt, comments omitted&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckRedirect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrUseLastResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Jar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each hop is a new request through the same guarded client, so each new connection passes &lt;code&gt;Control&lt;/code&gt;. Before following a hop, the checker also requires an &lt;code&gt;http&lt;/code&gt; or &lt;code&gt;https&lt;/code&gt; scheme, a hostname, no username or password in the URL, and either no explicit port or port 80 or 443, and it stops after 5 redirects.&lt;/p&gt;

&lt;p&gt;One more thing worth copying from it: errors shown to the user are turned into short fixed sentences. Blocked addresses, timeouts, redirect limits and unsupported redirect targets get their own fixed messages, and other connection failures become "could not connect to the address". The raw error text, which can contain internal addresses, never reaches the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;The address list is tested with literal addresses, including the ones that motivate it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/audit/ssrf_test.go, abridged: fewer entries, reasons shortened&lt;/span&gt;
&lt;span class="n"&gt;blocked&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"127.0.0.1"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="s"&gt;"loopback"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"10.0.0.5"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;"private network"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"172.17.0.2"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="s"&gt;"the Docker network the database sits on"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"169.254.169.254"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"cloud metadata service"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"100.64.0.1"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="s"&gt;"carrier-grade NAT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"fe80::1"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;         &lt;span class="s"&gt;"link-local"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client itself is tested by pointing it at a local &lt;code&gt;httptest&lt;/code&gt; server. That server listens on loopback, so the request must fail at the dialer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/audit/ssrf_test.go&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestPublicOnlyClient_RefusesLoopbackAtDial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;srv&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;httptest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secret"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;NewPublicOnlyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"a loopback server was reachable through the public-only client"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same fact makes the redirect limit awkward to test: a local test server can never be reached through the guarded client. So that test builds the client and then swaps in &lt;code&gt;http.DefaultTransport&lt;/code&gt;, which keeps the redirect policy under test while removing the dial check.&lt;/p&gt;

&lt;p&gt;What the tests do not do is simulate a real rebinding DNS server. The argument for the design is the one above: the check runs on the address being dialed, so there is no second lookup to race against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do not check a hostname and then fetch it. Check the IP inside the dialer, in &lt;code&gt;net.Dialer.Control&lt;/code&gt;, where the address is the one the connection will use.&lt;/li&gt;
&lt;li&gt;Refuse anything in &lt;code&gt;Control&lt;/code&gt; that is not a parseable IP.&lt;/li&gt;
&lt;li&gt;Turn off environment proxies for this client, or the check only sees the proxy.&lt;/li&gt;
&lt;li&gt;Treat the address list as a deny list for your own network, and add an egress firewall rule if you can.&lt;/li&gt;
&lt;li&gt;Follow redirects through the same guarded client and validate scheme, port and credentials on every hop.&lt;/li&gt;
&lt;li&gt;Never show raw connection errors to the user.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
    <item>
      <title>Counting visitors without cookies or storing IP addresses</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Thu, 29 Oct 2026 07:30:44 +0000</pubDate>
      <link>https://dev.to/radarmakers/counting-visitors-without-cookies-or-storing-ip-addresses-131g</link>
      <guid>https://dev.to/radarmakers/counting-visitors-without-cookies-or-storing-ip-addresses-131g</guid>
      <description>&lt;p&gt;We wanted three numbers for &lt;a href="https://radarmakers.com?ref=devto" rel="noopener noreferrer"&gt;RadarMakers&lt;/a&gt;: how many pages were viewed, roughly how many people viewed them each day, and where they came from. We did not want a cookie banner, a third-party script, or a table full of IP addresses. This post explains the implementation, a Go backend with Postgres and a Next.js frontend, and its limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets stored
&lt;/h2&gt;

&lt;p&gt;We do not store a separate event row for every page view. Every page view increments counters in a set of aggregate tables, each keyed by the UTC day: views and visitors per day, views per hour, per page, per referrer host, per country, per device class, per campaign, and per pair of internal pages for "came from this page to that page". A page view becomes "+1" in several of those tables, and there is no row that ties them together.&lt;/p&gt;

&lt;p&gt;The one table that is not a pure counter is &lt;code&gt;analytics_seen&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;analytics_seen&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;day&lt;/span&gt;     &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;visitor&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;visitor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exists only to tell whether a visitor was already counted today. The &lt;code&gt;visitor&lt;/code&gt; column holds a daily hash, never an IP address.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbciivl4ryny1sfonzweb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbciivl4ryny1sfonzweb.png" alt="From a page view to counts: the browser sends a beacon with the path, referrer and optional campaign; the server rejects bots and excluded paths and cleans the path; IP and User-Agent are used in memory for country, device and the daily hash; only aggregate counts and the UTC date with the daily hash are stored, never the raw IP or User-Agent" width="680" height="1060"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser side: a beacon and nothing else
&lt;/h2&gt;

&lt;p&gt;A small client component in the Next.js layout attempts one beacon each time the path changes to a page we count, skipping a repeat of the same path:&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="c1"&gt;// components/PageTracker.tsx, excerpt&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;countedPath&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;countedPath&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ... campaign comments omitted&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ref&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utm_source&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;countedPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/track&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It writes no cookie and nothing to local storage. The only state is two module variables that live as long as the loaded page. In a single page app &lt;code&gt;document.referrer&lt;/code&gt; stays the same after client side navigation, so the real referrer is sent only for the first counted page; after that the component sends the previous counted page of our own site. The server records that as an internal move when the previous page is a valid, counted path different from the current one.&lt;/p&gt;

&lt;p&gt;Before sending anything, the component checks a few things:&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="c1"&gt;// lib/track-optout.ts, called from components/PageTracker.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;skipTracking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TrackNavigator&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;doNotTrack&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalPrivacyControl&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webdriver&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Do Not Track is &lt;code&gt;"1"&lt;/code&gt;, Global Privacy Control is &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;navigator.webdriver&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, nothing is sent. Browsers driven by automation, such as Playwright or Selenium, set &lt;code&gt;navigator.webdriver&lt;/code&gt;; we added that check after our own page tests started to inflate the counts. MDN marks &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack" rel="noopener noreferrer"&gt;&lt;code&gt;navigator.doNotTrack&lt;/code&gt;&lt;/a&gt; as deprecated and non-standard, part of a discontinued specification, and points to &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigator/globalPrivacyControl" rel="noopener noreferrer"&gt;Global Privacy Control&lt;/a&gt; as one of the alternatives browsers are exploring. We choose to skip counting when either privacy signal is present. The component also waits until it knows whether the user is logged in and sends nothing for administrators; that exclusion lives in this client component, not in the public endpoint. Both the component and the server skip the admin panel, account pages and the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server side: filter, derive, forget
&lt;/h2&gt;

&lt;p&gt;The Go handler answers &lt;code&gt;204 No Content&lt;/code&gt;, also when it drops a request, so the response does not reveal the filter rules; the route's rate limiter in front of it can still answer &lt;code&gt;429&lt;/code&gt;. The handler reads up to 2048 bytes of the body for parsing, then reads and discards up to another 1 MiB before answering. It drops empty and bot-like User-Agents, such as anything containing &lt;code&gt;bot&lt;/code&gt;, &lt;code&gt;crawl&lt;/code&gt;, &lt;code&gt;headless&lt;/code&gt; or &lt;code&gt;curl&lt;/code&gt;, and paths it does not count. It cuts the query string and fragment off the path, so &lt;code&gt;/products/x?secret=1&lt;/code&gt; is counted as &lt;code&gt;/products/x&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Referrers are reduced to a host. A visit from a search results page is stored as &lt;code&gt;google.com&lt;/code&gt;, not as the full URL with the search terms. A referrer from our own site is not a source at all; it becomes the "from" side of an internal move.&lt;/p&gt;

&lt;p&gt;Then come the two personal inputs, the IP address and the User-Agent. They are used in memory for three things and then dropped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the country, looked up in a local database file, or &lt;code&gt;??&lt;/code&gt; when unknown;&lt;/li&gt;
&lt;li&gt;the device class, &lt;code&gt;desktop&lt;/code&gt;, &lt;code&gt;mobile&lt;/code&gt; or &lt;code&gt;tablet&lt;/code&gt;, from a few substrings of the User-Agent;&lt;/li&gt;
&lt;li&gt;the daily visitor hash.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/handler/analytics.go&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;visitorHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ua&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum256&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"|"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2006-01-02"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"|"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"|"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;32&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hash covers an application secret, the UTC date, the IP address and the User-Agent, and only the first 32 hex characters are kept. Requests with the same IP address and the exact same User-Agent string get the same hash within a UTC day, as long as the secret does not change, and a different hash on the next day, because the date is part of the input.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fggxwhik7ty5jdmt276tl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fggxwhik7ty5jdmt276tl.png" alt="A visitor key for one UTC day: app secret, UTC date, IP and User-Agent are joined and hashed with SHA-256, the first 32 hex characters are kept; a new row in analytics_seen adds one visitor and one view, an existing row adds one view only; the next UTC date changes the hash input; cleanup at startup and then hourly deletes hashes from earlier dates and aggregate rows older than 400 days" width="680" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting a unique visitor with one insert
&lt;/h2&gt;

&lt;p&gt;The repository records a page view in one transaction. The first statement decides whether this is a new visitor for the day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/repository/analytics.go, excerpt&lt;/span&gt;
&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExecContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;`INSERT INTO analytics_seen(day, visitor) VALUES($1::date, $2) ON CONFLICT DO NOTHING`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Visitor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RowsAffected&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;newVisitor&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the row is new, &lt;code&gt;RowsAffected&lt;/code&gt; is 1; if the hash was already there, the conflict is ignored and it is 0. That number is then added to the daily visitors counter and the per-country visitors counter, while views always get 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;analytics_daily&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;visitors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;analytics_daily&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;views&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;visitors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;analytics_daily&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;visitors&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other counters follow the same &lt;code&gt;INSERT ... ON CONFLICT DO UPDATE&lt;/code&gt; pattern. Since paths and referrer hosts come from strangers, the tables for pages, referrers, internal moves and traffic sources have a daily threshold of distinct values, for example 5000 paths. Once a threshold is reached, new values go into a single "other" row instead. The check and the insert are separate queries, so concurrent requests can overshoot a threshold a little; it is a guard against flooding, not an exact limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up
&lt;/h2&gt;

&lt;p&gt;A goroutine started with the server runs the cleanup at startup and then once an hour. Each run deletes every &lt;code&gt;analytics_seen&lt;/code&gt; row from an earlier UTC date and aggregate rows older than 400 days. This follows UTC dates, not an exact 24 hour lifetime: a hash written at 23:59 UTC stays until the first successful run after midnight, which is usually within the hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does and does not promise
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is an estimate.&lt;/strong&gt; Two people whose requests come from the same IP address with the exact same User-Agent string count as one visitor. One person whose IP address changes during the day, for example when switching from Wi-Fi to mobile data, usually counts as two. A visitor who opts out or blocks the beacon is not counted at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hash is not anonymous to us.&lt;/strong&gt; Anyone with the application secret and a guess of someone's IP address and User-Agent could recompute that day's hash and see whether it is in the table. That is part of why the hashes are deleted by the first cleanup after the UTC day ends. The aggregate tables hold no hashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The claim is about the analytics tables.&lt;/strong&gt; The IP address and User-Agent are not written to any of them. Your reverse proxy, web server or hosting provider may still keep access logs with IP addresses; check those separately if you make a promise to your users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The counters use no stable visitor key across days.&lt;/strong&gt; They do not report returning visitors or individual browsing histories, only single internal moves from one page to another.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How we test it
&lt;/h2&gt;

&lt;p&gt;The handler test runs against a real disposable Postgres database. It sends rejected requests and checks that nothing was counted, sends realistic ones, moves an injected clock to the next day, and finally reads every row of the analytics tables it lists as text and fails if any of them contains the test IP address or the strings &lt;code&gt;Firefox&lt;/code&gt; or &lt;code&gt;Mozilla&lt;/code&gt; from the test User-Agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/handler/analytics_test.go, excerpt&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testIP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Firefox"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Mozilla"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s stores personal data: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tbl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also fails if there were no rows to inspect, because a check that looks at an empty database proves nothing. A separate unit test checks that the hash is stable within a day and changes with the date, the IP address, the User-Agent and the secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Store counts, not visits. A row per page view is a log, whatever you call it.&lt;/li&gt;
&lt;li&gt;If you need daily uniques, a hash of a secret, the date, the IP and the User-Agent, deleted after the day ends, plus an &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt;, gives you an estimate without a cookie.&lt;/li&gt;
&lt;li&gt;Reduce referrers to hosts and strip query strings from paths before they reach the database.&lt;/li&gt;
&lt;li&gt;Put a threshold on distinct values in tables that strangers can fill with made-up paths and hosts.&lt;/li&gt;
&lt;li&gt;Write the test that greps your tables for the test IP address and User-Agent. It can catch accidental storage of those values in the tables it scans.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>privacy</category>
      <category>go</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Open Graph images for every page with Next.js ImageResponse</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:00:08 +0000</pubDate>
      <link>https://dev.to/radarmakers/open-graph-images-for-every-page-with-nextjs-imageresponse-j6m</link>
      <guid>https://dev.to/radarmakers/open-graph-images-for-every-page-with-nextjs-imageresponse-j6m</guid>
      <description>&lt;p&gt;A link to a product page should show that product in the preview card, not the same site banner as every other link. On our launch platform, live product pages use a 1200 x 630 PNG generated on the server with &lt;code&gt;ImageResponse&lt;/code&gt; from &lt;code&gt;next/og&lt;/code&gt;. Tag pages use the same generator except for eight tags: &lt;code&gt;ai&lt;/code&gt;, &lt;code&gt;ai-agents&lt;/code&gt;, &lt;code&gt;audio&lt;/code&gt;, &lt;code&gt;design&lt;/code&gt;, &lt;code&gt;finance&lt;/code&gt;, &lt;code&gt;music&lt;/code&gt;, &lt;code&gt;open-source&lt;/code&gt; and &lt;code&gt;productivity&lt;/code&gt;. Those tags use static Open Graph images at &lt;code&gt;/og/tag/&amp;lt;slug&amp;gt;.png&lt;/code&gt; and separate Twitter images at &lt;code&gt;/og/x/tag/&amp;lt;slug&amp;gt;.png&lt;/code&gt;. This is the image for one of the products listed on &lt;a href="https://radarmakers.com/product/icon-cutter?ref=devto" rel="noopener noreferrer"&gt;RadarMakers&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ful8qxh7j2vyv6r5omz9t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ful8qxh7j2vyv6r5omz9t.png" alt="A generated preview image: the RadarMakers mark, the label PRODUCT, the product name IconCutter, its tagline on two lines, and the product logo in a framed square on the right" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing in that image was drawn by hand for this product. The layout is shared, the name, tagline and logo come from the database, and the text sizes were picked by measuring the text before rendering. Here is how the pieces fit together, using our real code on Next.js 16.&lt;/p&gt;

&lt;h2&gt;
  
  
  A route handler per image, a versioned URL in the metadata
&lt;/h2&gt;

&lt;p&gt;Next.js offers two ways to serve a generated image. The &lt;code&gt;opengraph-image.tsx&lt;/code&gt; file convention adds the image to a route segment and writes the &lt;code&gt;og:image&lt;/code&gt; tags for you. Or you can return an &lt;a href="https://nextjs.org/docs/app/api-reference/functions/image-response" rel="noopener noreferrer"&gt;&lt;code&gt;ImageResponse&lt;/code&gt;&lt;/a&gt; from an ordinary route handler and point your metadata at it yourself.&lt;/p&gt;

&lt;p&gt;We use route handlers: &lt;code&gt;/product-og/[slug]&lt;/code&gt; and &lt;code&gt;/tag-og/[slug]&lt;/code&gt;. The product page's &lt;code&gt;generateMetadata&lt;/code&gt; builds the image URL with a version taken from the data that appears in the image:&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="c1"&gt;// app/product/[slug]/layout.tsx, excerpt&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ogImages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/product-og/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;?v=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;ogVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; on RadarMakers`&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and passes &lt;code&gt;ogImages&lt;/code&gt; to both &lt;code&gt;openGraph.images&lt;/code&gt; and &lt;code&gt;twitter.images&lt;/code&gt;. Doing it this way gives us three things we rely on below: a URL version derived from the product name, tagline and logo URL, our own cache headers, and a fallback image when something fails.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fntllho15e34tvyoa51vl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fntllho15e34tvyoa51vl.png" alt="Product and tag data go through a memory cache; on a miss the shared layout, fitted text and DejaVu Sans fonts go into ImageResponse, which returns a 1200 x 630 PNG with long cache headers; missing data or a render error returns a static fallback PNG with a short cache" width="800" height="912"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One shared layout
&lt;/h2&gt;

&lt;p&gt;Product and tag images share one file, &lt;code&gt;lib/og-shared.tsx&lt;/code&gt;. It holds the size, the colors, the fonts and the background:&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="c1"&gt;// lib/og-shared.tsx, excerpt, comments omitted&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OG_WIDTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OG_HEIGHT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ... colors and the Fonts type omitted&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;fontsPromise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Fonts&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadFonts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Fonts&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;fontsPromise&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/assets/og&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;regular&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DejaVuSans.ttf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DejaVuSans-Bold.ttf&lt;/span&gt;&lt;span class="dl"&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;{&lt;/span&gt; &lt;span class="nx"&gt;regular&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;regularMetrics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;readFontMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;regular&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;boldMetrics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;readFontMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bold&lt;/span&gt;&lt;span class="p"&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;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;fontsPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&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="nx"&gt;fontsPromise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fonts are read once per process and kept as a promise. If reading fails, the promise is cleared, so the next request tries again instead of failing forever. The same function also reads the font metrics we need for measuring text, which comes up in a moment.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Backdrop()&lt;/code&gt; returns one SVG with everything that never changes: the curved panel, the radar rings, the frame for the logo, our mark and the footer line. Each image is then a 1200 x 630 &lt;code&gt;div&lt;/code&gt; with the backdrop and a few absolutely positioned text blocks on top.&lt;/p&gt;

&lt;p&gt;The Next.js docs list the limits to design for: only flexbox and a subset of CSS, no &lt;code&gt;display: grid&lt;/code&gt;, and only &lt;code&gt;ttf&lt;/code&gt;, &lt;code&gt;otf&lt;/code&gt; and &lt;code&gt;woff&lt;/code&gt; fonts. Our layout stays inside those limits: the root is a flex container, and every text block is an absolutely positioned &lt;code&gt;div&lt;/code&gt; with &lt;code&gt;display: flex&lt;/code&gt; and &lt;code&gt;whiteSpace: "nowrap"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per page data
&lt;/h2&gt;

&lt;p&gt;The product image takes three fields:&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="c1"&gt;// lib/og-product.tsx, excerpt&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderProductOg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Pick&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tagline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;logo_url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fonts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadFonts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;logo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchLogo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logo_url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fitName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;boldMetrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tagline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wrapTagline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;regularMetrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagline&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tag image takes only the tag name and draws a fixed tag illustration where the logo would be. Both end the same way: &lt;code&gt;new ImageResponse(element, { width: OG_WIDTH, height: OG_HEIGHT, fonts: satoriFonts(fonts) })&lt;/code&gt;, read into a &lt;code&gt;Uint8Array&lt;/code&gt; so the bytes can be cached.&lt;/p&gt;

&lt;p&gt;The logo needed the most care, because it is user content. &lt;code&gt;fetchLogo&lt;/code&gt; only accepts a path of the form &lt;code&gt;/uploads/&amp;lt;file name&amp;gt;&lt;/code&gt;, fetches it from our own API origin with a 4 second timeout, rejects an empty body or one larger than 2 MiB after downloading it, and checks the first bytes of the file for PNG, JPEG, GIF or SVG instead of trusting the extension. Anything else returns &lt;code&gt;null&lt;/code&gt;, and the image shows a tile with the first letter of the product name. The renderer never fetches an address that came from a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring text before drawing it
&lt;/h2&gt;

&lt;p&gt;Our design template has a fixed box for the name: 552 px wide on one line. A short name should stay at 60 px. A longer name should get smaller, down to 40 px. A name too long even at 40 px gets cut and ends with three periods. The tagline gets at most two lines of 30 px.&lt;/p&gt;

&lt;p&gt;CSS has no rule for "shrink this text until it fits the box", so we measure. &lt;code&gt;lib/og-text.ts&lt;/code&gt; reads advance widths straight from the font file: it looks each character up in the font's Unicode BMP &lt;code&gt;cmap&lt;/code&gt; table and takes the width from &lt;code&gt;hmtx&lt;/code&gt;. Characters above &lt;code&gt;U+FFFF&lt;/code&gt;, such as most emoji, and characters the font does not have get the width of the font's fallback glyph, so text full of them is measured only roughly. Then it adds up the widths:&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="c1"&gt;// lib/og-text.ts, excerpt, comments omitted&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;textWidth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FontMetrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;letterSpacing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;letterSpacing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fitting the name is then a loop from the largest size down, in 2 px steps:&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="c1"&gt;// lib/og-text.ts, excerpt, comments omitted&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SAFETY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.03&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NAME_BOX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;552&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NAME_MAX_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NAME_MIN_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ... tagline constants and the clean and truncate helpers omitted;&lt;/span&gt;
&lt;span class="c1"&gt;// clean collapses whitespace, truncate cuts until the text plus "..." fits&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fitName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FontMetrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rawName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NAME_BOX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NAME_MAX_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NAME_MIN_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;letterSpacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spacing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;textWidth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;spacing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;SAFETY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;size&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;box&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;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;letterSpacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;spacing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&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;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;letterSpacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;spacing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details are worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The safety factor.&lt;/strong&gt; Our measurement ignores kerning and rounding, so every width is multiplied by 1.03 before it is compared with the box. A few percent of slack is cheaper than a name that touches the logo frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The letter spacing scales with the size.&lt;/strong&gt; The template uses -1.8 px at 60 px, so a smaller size gets proportionally less negative spacing, and the measurement includes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The font is the same file everywhere.&lt;/strong&gt; The renderer draws with the DejaVu Sans files we pass in &lt;code&gt;fonts&lt;/code&gt;, and the measurement reads the same files. Measuring with one font and drawing with another would make the numbers meaningless.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv4f0gqfvpggo2x83bi0u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv4f0gqfvpggo2x83bi0u.png" alt="Fitting the name: collapse whitespace, measure against a 552 px box with glyph widths, letter spacing and a 1.03 factor, try 60 px down to 40 px; if a size fits keep the full name, if it is still too wide at 40 px shorten it and add three periods; product taglines use 30 px and at most two lines" width="800" height="744"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wrapTagline&lt;/code&gt; does the same for the tagline: it fills the first line word by word, puts the rest on the second, and cuts the second line with three periods if needed. A single word longer than a line is broken where it stops fitting.&lt;/p&gt;

&lt;p&gt;Because the result of all this is plain data, a name and a size, the text is placed in the JSX with fixed coordinates. One more helper, &lt;code&gt;textBox&lt;/code&gt;, turns the template's baseline into a &lt;code&gt;top&lt;/code&gt; and a &lt;code&gt;lineHeight&lt;/code&gt; from the font's ascent and descent, so text sits exactly where the design file puts it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching at three levels
&lt;/h2&gt;

&lt;p&gt;Rendering a PNG is the slow part, and preview crawlers ask for the same image again and again. We cache at three levels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The URL.&lt;/strong&gt; &lt;code&gt;ogVersion&lt;/code&gt; is a short FNV-1a hash of the name, tagline and logo URL. It goes into the &lt;code&gt;?v=&lt;/code&gt; parameter of the image URL in the metadata. When a maker renames a product, the page starts pointing at a new image URL. That helps a platform that fetches the page again pick up the new image. It does not force any platform to fetch the page again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The process.&lt;/strong&gt; The route handler keeps rendered PNGs in a small in-memory cache, keyed by slug and version:&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="c1"&gt;// app/product-og/[slug]/route.tsx, excerpt&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getProductBySlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;ogVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ogCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;renderProductOg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ogCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;png&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;png&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;BodyInit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IMAGE_HEADERS&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OgCache&lt;/code&gt; is a &lt;code&gt;Map&lt;/code&gt; used as a least recently used list: up to 200 entries, each expiring after six hours. Product and tag images share it, with a &lt;code&gt;tag:&lt;/code&gt; prefix on tag keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP.&lt;/strong&gt; Successful images go out with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Content-Type: image/png
Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fallback.&lt;/strong&gt; If the product does not exist, is not live, or anything throws, the handler returns a static PNG with &lt;code&gt;Cache-Control: public, max-age=300&lt;/code&gt;. The fallback has a five minute freshness lifetime, while a real image stays fresh for an hour in browser caches and a day in shared caches. After the five minutes, a cache that respects the header asks again, which matters for a product that was waiting for review and has since gone live. A sharing service may still keep its own copy of the preview longer than that.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we test it
&lt;/h2&gt;

&lt;p&gt;The fitting and caching code has no React and no renderer in it, so it runs under &lt;code&gt;node --test&lt;/code&gt;. The tests load the real font files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/og-text.test.mjs, excerpt&lt;/span&gt;
&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a name too long even at 40 px is truncated with three periods and fits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fitName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;An extraordinarily long product name that cannot possibly fit in one line&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;textWidth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;letterSpacing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;NAME_BOX&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other tests check the font metrics against known values from the DejaVu tables, that a tagline wraps into two lines that both fit, that identical inputs give the same version and that changing the name, the tagline or the logo URL gives a different one, and that the cache evicts the least recently used entry and drops expired ones with an injected clock. The tag page test loads the real page module with a fake lookup for &lt;code&gt;ai-tools&lt;/code&gt;, a tag without a static card, and checks that its metadata points at a versioned &lt;code&gt;/tag-og/ai-tools&lt;/code&gt; URL and that renaming this tag changes that address.&lt;/p&gt;

&lt;p&gt;These tests do not render a PNG. For that we check the live site after a deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"Slackbot-LinkExpanding 1.0"&lt;/span&gt; https://yoursite.com/product/some-slug &lt;span class="nt"&gt;-o&lt;/span&gt; page.html
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;meta property="og:image" content="[^"]*"'&lt;/span&gt; page.html
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; - &lt;span class="nt"&gt;-o&lt;/span&gt; og.png &lt;span class="s2"&gt;"https://yoursite.com/product-og/some-slug?v=..."&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"^HTTP|content-type|cache-control"&lt;/span&gt;
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"from PIL import Image; print(Image.open('og.png').size)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then open the PNG and look at it. When we last did this, the product page above pointed at its versioned image, the image answered 200 with &lt;code&gt;image/png&lt;/code&gt; and the long cache header, and it measured 1200 x 630. An unknown slug answered 200 with the fallback image and &lt;code&gt;max-age=300&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Point your metadata at a route that renders the image, and put a version of the image's inputs in the URL.&lt;/li&gt;
&lt;li&gt;Keep one shared layout and pass only the page data in. Our product and tag images differ by a label, a field or two and an illustration.&lt;/li&gt;
&lt;li&gt;If the design needs text to shrink or stop at a number of lines, measure it with the same font file the renderer uses, add a little slack, and place the result with fixed coordinates.&lt;/li&gt;
&lt;li&gt;Never let the renderer fetch a URL a user typed. Fetch from your own storage, check the bytes, reject files that are too large.&lt;/li&gt;
&lt;li&gt;Cache the PNG bytes in the process, send long cache headers for real images and short ones for the fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you generate preview images differently, for example with the &lt;code&gt;opengraph-image&lt;/code&gt; file convention, I would like to hear what made you choose it.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>Auditing your own backlinks with a Python script: dedupe hosts, check rel, find what is live</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:30:04 +0000</pubDate>
      <link>https://dev.to/radarmakers/auditing-your-own-backlinks-with-a-python-script-dedupe-hosts-check-rel-find-what-is-live-531g</link>
      <guid>https://dev.to/radarmakers/auditing-your-own-backlinks-with-a-python-script-dedupe-hosts-check-rel-find-what-is-live-531g</guid>
      <description>&lt;p&gt;Liquid syntax error: 'details' tag was never closed&lt;/p&gt;
</description>
      <category>webdev</category>
      <category>seo</category>
      <category>startup</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I built 8 free maker tools: six never upload your files, two fetch a URL for you</title>
      <dc:creator>RadarMakers</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:54:32 +0000</pubDate>
      <link>https://dev.to/radarmakers/i-built-8-free-maker-tools-six-never-upload-your-files-two-fetch-a-url-for-you-46hf</link>
      <guid>https://dev.to/radarmakers/i-built-8-free-maker-tools-six-never-upload-your-files-two-fetch-a-url-for-you-46hf</guid>
      <description>&lt;p&gt;Launching a small product means a pile of small chores: a favicon set, a few screenshots in a nice frame, tracked links for every channel, a changelog, a launch checklist, and a check that your link previews actually work. We built tools for these into &lt;a href="https://radarmakers.com/tools" rel="noopener noreferrer"&gt;RadarMakers&lt;/a&gt;, a launch platform for indie makers, and made them free with no account.&lt;/p&gt;

&lt;p&gt;There are eight of them, and they split into two honest groups. Six run entirely in your browser. Two need to fetch a public page, so they send the address you type to our server. This post explains both groups and the code pattern that keeps all eight easy to test.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fje0p40ben7t6ra0dznay.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fje0p40ben7t6ra0dznay.png" alt="All eight tools grouped into six browser tools and two server URL checkers" width="800" height="904"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The six that stay in your browser
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Launch UTM link builder.&lt;/strong&gt; Builds tracked links for many channels and destination pages at once, imports a CSV, checks that campaign names are spelled consistently and downloads a campaign sheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launch date planner.&lt;/strong&gt; Turns a launch date into a dated task list you can tick off and export as a calendar file or Markdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshot mockup and launch image exporter.&lt;/strong&gt; Frames a real screenshot with padding, a background and a shadow, lets you blur private details, and exports launch images at several sizes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Favicon and PWA icon package builder.&lt;/strong&gt; Turns one logo into a favicon.ico, PNG sizes and a web manifest, with a real 16 pixel preview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch image optimizer with a file size target.&lt;/strong&gt; Compresses several screenshots towards a target size and gives you the files plus a CSV report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changelog and release notes generator.&lt;/strong&gt; Turns a list of changes into a Markdown or HTML changelog, grouped by type.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four of these accept files, and each reads them in the page: the UTM builder reads a CSV with &lt;code&gt;FileReader.readAsText&lt;/code&gt;, the favicon builder loads the logo through an object URL into an &lt;code&gt;Image&lt;/code&gt;, and the screenshot mockup and image optimizer read the file with &lt;code&gt;file.arrayBuffer()&lt;/code&gt; and decode it with &lt;code&gt;createImageBitmap&lt;/code&gt;. The results are drawn on a canvas or built in JavaScript and offered as downloads made from a &lt;code&gt;Blob&lt;/code&gt;, or a ZIP for the favicon package. These six tools process your working input locally and create downloads in your browser. They do not upload your files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two that need a server
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;meta tag checker&lt;/strong&gt; and the &lt;strong&gt;Open Graph and social card checker&lt;/strong&gt; answer a different question: "what does this live page actually tell search engines and social networks?" A browser cannot reliably read arbitrary sites unless those sites allow cross origin access. So these two tools send the address to our backend. The server fetches the page, follows permitted redirects, reads its tags and checks any referenced share images. It reads the HTML without running the page's JavaScript, so the previews approximate what each platform shows rather than reproduce it exactly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn2dy45xw4rnzm0ml8m6i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn2dy45xw4rnzm0ml8m6i.png" alt="Your browser sends the URL, our server fetches the page, follows redirects and checks share images, and your browser shows the report" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We say this on the page, in plain words, above the input:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The address you type is sent to our server. The server downloads that public page once, reads its tags and sends the result back. Neither the address nor the result is stored, and the limit is 20 checks per minute.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That wording is a little shorter than reality, since the server also follows redirects and checks share images, as described above. On the server side, three decisions matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The handler and the inspector do not log or persist the submitted URL or the inspection result.&lt;/li&gt;
&lt;li&gt;The fetch goes through a dialer that rejects loopback, private, link local and other blocked IP ranges on every connection.&lt;/li&gt;
&lt;li&gt;A rate limiter allows 20 requests per minute per IP, shared by both checkers.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// cmd/server/main.go, excerpt&lt;/span&gt;
&lt;span class="n"&gt;inspector&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewPublicOnlyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;inspectLimiter&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;custommw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRateLimiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;With&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inspectLimiter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Middleware&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/tools/inspect-url"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewToolsInspectHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inspector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Inspect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both checkers also have a manual mode, where you type a title and description or pick an image from your disk to preview a card. That mode stays local like the other six tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that keeps them testable
&lt;/h2&gt;

&lt;p&gt;We separate reusable processing logic from React components, with shared inspection code for both URL checkers. Most tools have a &lt;code&gt;logic.ts&lt;/code&gt; with pure functions and no React or DOM, next to a component that handles input, canvas drawing and downloads. Each tool has its own test file that runs in plain Node with &lt;code&gt;node --test&lt;/code&gt;. Today the eight tools plus their shared URL inspector have 199 test declarations between them, covering the logic, some component interactions, generated files and server rendered page HTML.&lt;/p&gt;

&lt;p&gt;A small example from the UTM builder. Campaign names that differ only in case or spacing split your analytics into separate rows, so the tool normalizes them and tells you what it removed instead of silently transliterating:&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="c1"&gt;// utm-link-builder/logic.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cleanValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;separator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Separator&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;normalize&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;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;char&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9_&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;separator&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;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/utm-link-builder.test.mjs, excerpt&lt;/span&gt;
&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cleanValue lowercases, converts whitespace and reports skipped characters without transliteration&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cleanValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;  Café Launch!! &lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;2026 &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;caf-launch-2026&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;é&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the logic does not touch the DOM, the same functions run in the browser and in the test runner. Canvas drawing is not covered by these Node tests. In the image optimizer, the encoder is passed into the logic as a callback, so the tests exercise our size targeting rather than the browser's codecs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do the same way again
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Say where the data goes.&lt;/strong&gt; "Runs in your browser" is a strong promise. When a tool cannot keep it, say so next to the input, not in a privacy policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build downloads from Blobs.&lt;/strong&gt; For images, CSV and text, the browser can do the whole job, so there is no reason to upload a file only to hand it back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep logic out of components where you can.&lt;/strong&gt; Pure functions are what make a refactor safe to test without clicking through the page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tools are free to use without an account. If you build tools like these, I would like to hear how you handle the line between local processing and server fetches.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
