<?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: mrnoyy</title>
    <description>The latest articles on DEV Community by mrnoyy (@mrnoyy).</description>
    <link>https://dev.to/mrnoyy</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%2F4095169%2F92ca4b20-182f-44f6-8d86-d428cae89293.png</url>
      <title>DEV Community: mrnoyy</title>
      <link>https://dev.to/mrnoyy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mrnoyy"/>
    <language>en</language>
    <item>
      <title>Your Pages Are Not Indexed — a Debugging Checklist That Starts With the Boring Causes</title>
      <dc:creator>mrnoyy</dc:creator>
      <pubDate>Thu, 17 Sep 2026 09:12:48 +0000</pubDate>
      <link>https://dev.to/mrnoyy/your-pages-are-not-indexed-a-debugging-checklist-that-starts-with-the-boring-causes-39kp</link>
      <guid>https://dev.to/mrnoyy/your-pages-are-not-indexed-a-debugging-checklist-that-starts-with-the-boring-causes-39kp</guid>
      <description>&lt;p&gt;A site ships, weeks pass, and the pages are still not in the index. The usual advice jumps straight to content quality. Before that, there is a list of mechanical causes that are far more common and much easier to verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Can it actually be fetched
&lt;/h2&gt;

&lt;p&gt;Use the URL inspection tool and fetch the live page. Not your browser — your browser has your cookies, your session, and possibly a cached copy.&lt;/p&gt;

&lt;p&gt;Things that show up here regularly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The page returns 200 to you and 403 to the crawler, usually via a bot-protection rule.&lt;/li&gt;
&lt;li&gt;The HTML is nearly empty because content renders client-side after a fetch the crawler did not wait for.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;noindex&lt;/code&gt; tag left over from staging. This is embarrassingly common and takes ten seconds to check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check &lt;code&gt;robots.txt&lt;/code&gt; too. A &lt;code&gt;Disallow&lt;/code&gt; written for a staging path can match more than intended.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Canonical tags pointing elsewhere
&lt;/h2&gt;

&lt;p&gt;If page A declares page B as canonical, you have asked search engines not to index A. That is working as designed.&lt;/p&gt;

&lt;p&gt;The ways this happens by accident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A template with a hardcoded canonical, so every page claims the homepage.&lt;/li&gt;
&lt;li&gt;Cross-posting to a platform without setting the canonical there, so the copy on the bigger domain wins.&lt;/li&gt;
&lt;li&gt;Protocol or host mismatch — canonical says &lt;code&gt;https://www.example.com/page&lt;/code&gt;, the site serves &lt;code&gt;https://example.com/page&lt;/code&gt;. Those are different URLs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every URL should declare itself canonical unless you genuinely mean otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Redirect chains from trailing slashes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/page&lt;/code&gt; redirects to &lt;code&gt;/page/&lt;/code&gt;, which redirects to &lt;code&gt;https://example.com/page/&lt;/code&gt;. Each hop costs a request and dilutes the signal, and if your sitemap lists the pre-redirect form, every crawl starts by bouncing.&lt;/p&gt;

&lt;p&gt;Pick a form. Make internal links, the sitemap, and the canonical all use it. Then check what your host does automatically, because it may be adding a hop you did not configure.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Sitemap says one thing, the site says another
&lt;/h2&gt;

&lt;p&gt;A sitemap generated from your content config lists what &lt;em&gt;should&lt;/em&gt; exist. If the build dropped a page, the sitemap still lists it and you are pointing crawlers at 404s. Enough of those and the whole file is trusted less.&lt;/p&gt;

&lt;p&gt;Generate the sitemap from the built output on disk, not from the source config. Then it cannot describe pages that were not shipped.&lt;/p&gt;

&lt;p&gt;While you are there: &lt;code&gt;lastmod&lt;/code&gt; should reflect real changes. Setting it to today's date on every build teaches crawlers that your dates mean nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Slugs that changed without redirects
&lt;/h2&gt;

&lt;p&gt;If URLs changed during a redesign and the old ones return 404, you did not migrate — you deleted a site and published a new one. Everything the old URLs had earned is gone.&lt;/p&gt;

&lt;p&gt;Old URL to new URL, 301, one hop each. Keep them indefinitely. Links on other people's sites do not update themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Near-duplicate pages competing
&lt;/h2&gt;

&lt;p&gt;Twelve pages built from one template with a city name swapped in are, functionally, one page. Search engines pick one and ignore the rest, and "crawled, currently not indexed" is exactly what that looks like.&lt;/p&gt;

&lt;p&gt;The fix is not more pages. It is fewer pages that each say something the others do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. It has genuinely not been long enough
&lt;/h2&gt;

&lt;p&gt;New domains get crawled slowly. Weeks is normal, not a symptom. Submitting the same URL repeatedly does not speed it up.&lt;/p&gt;

&lt;p&gt;Do the useful things instead: internal links from pages that are already indexed, a clean sitemap referenced in &lt;code&gt;robots.txt&lt;/code&gt;, and links from somewhere real.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on tooling
&lt;/h2&gt;

&lt;p&gt;Check more than one search engine's webmaster tools. They surface different diagnostics, and a crawl problem that shows up in one is often the same problem in both — sometimes reported more clearly on the side you were not looking at.&lt;/p&gt;




&lt;p&gt;The pattern I keep seeing: the cause is almost always in items 1 through 5, and almost always something mechanical that a person could have checked on day one. Content is worth improving. It is just rarely the reason a page is missing from the index entirely.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>googlesearchconsole</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Building a Store Without a Payment Gateway — Manual Checkout on Next.js and Supabase</title>
      <dc:creator>mrnoyy</dc:creator>
      <pubDate>Tue, 15 Sep 2026 09:14:06 +0000</pubDate>
      <link>https://dev.to/mrnoyy/building-a-store-without-a-payment-gateway-manual-checkout-on-nextjs-and-supabase-ccm</link>
      <guid>https://dev.to/mrnoyy/building-a-store-without-a-payment-gateway-manual-checkout-on-nextjs-and-supabase-ccm</guid>
      <description>&lt;p&gt;Not every market has easy access to Stripe. In a lot of places the real payment rails are bank transfer and mobile wallets, confirmed by a human. That changes the architecture more than you would think: without a gateway webhook, &lt;em&gt;your database is the source of truth for payment state&lt;/em&gt;, and you have to design it that way deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order state machine
&lt;/h2&gt;

&lt;p&gt;Five states, one direction, no shortcuts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pending → awaiting_payment → payment_submitted → confirmed → fulfilled
                                    ↓
                                 rejected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pending&lt;/code&gt; — cart submitted, nothing else&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;awaiting_payment&lt;/code&gt; — customer has the transfer details&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;payment_submitted&lt;/code&gt; — customer says they paid and gave a reference&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;confirmed&lt;/code&gt; — a human matched it against the account&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rejected&lt;/code&gt; — no match found&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important rule: &lt;strong&gt;only &lt;code&gt;confirmed&lt;/code&gt; unlocks anything&lt;/strong&gt;. &lt;code&gt;payment_submitted&lt;/code&gt; is a customer claim, not a fact. Treating it as one is how you get taken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema
&lt;/h2&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;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;reference&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;unique&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;status&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="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;customer_name&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="n"&gt;customer_phone&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="n"&gt;total_amount&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&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;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;payment_method&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payment_reference&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;confirmed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;confirmed_by&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&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="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;orders&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="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;products&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="n"&gt;quantity&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;unit_price&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&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;not&lt;/span&gt; &lt;span class="k"&gt;null&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;unit_price&lt;/code&gt; is copied onto the line item, not read through the product. Prices change; an order is a record of what was agreed at the time.&lt;/p&gt;

&lt;p&gt;The short human-readable &lt;code&gt;reference&lt;/code&gt; matters more than it looks. Customers type it into a transfer note and read it over the phone. A UUID is unusable for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Row Level Security is not optional
&lt;/h2&gt;

&lt;p&gt;Supabase exposes your tables directly to the browser. The anon key is public — it is in your JavaScript. Without RLS, anyone can read every order in your database.&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;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"insert own order"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"staff read all"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;staff&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&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;Note what the insert policy does: an anonymous visitor can create an order, but only in &lt;code&gt;pending&lt;/code&gt;. They cannot post one that is already &lt;code&gt;confirmed&lt;/code&gt;. Every status transition after that happens server-side under a service role, never from the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Total on the server
&lt;/h2&gt;

&lt;p&gt;The client sends product ids and quantities. It does not send a total.&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id, price&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="k"&gt;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;items&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;i&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;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product_id&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&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;product&lt;/span&gt; &lt;span class="o"&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;find&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;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product_id&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown product&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="nx"&gt;sum&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;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;;&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything the browser sends is a suggestion. If a price can be set client-side, it will be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency on submit
&lt;/h2&gt;

&lt;p&gt;A customer on a slow connection taps the button three times. Without protection that is three orders and three confused phone calls.&lt;/p&gt;

&lt;p&gt;Generate an idempotency key when the checkout form mounts, send it with the order, and put a unique constraint on it. The second and third submit hit the constraint and return the existing order instead of creating a new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The admin panel is the product
&lt;/h2&gt;

&lt;p&gt;With a gateway, a webhook flips the status. Without one, a person does. So that screen deserves real attention:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Orders sorted oldest first — the customer waiting longest gets served first.&lt;/li&gt;
&lt;li&gt;The payment reference copyable in one tap, because they are cross-checking it against a bank statement.&lt;/li&gt;
&lt;li&gt;Confirm and reject as separate, deliberate actions with a note field.&lt;/li&gt;
&lt;li&gt;Every transition written to an audit table with a user id. When something is disputed weeks later, "who confirmed this and when" is the only question that matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you gain
&lt;/h2&gt;

&lt;p&gt;No processing fees, no chargebacks, no gateway approval process. That is a genuinely good trade for a small catalogue in a market where cards are not the norm.&lt;/p&gt;

&lt;p&gt;What you pay for it is latency. An order sits until a human looks at it, so set the expectation on the confirmation screen: tell the customer the window, and send something when the status changes. Most complaints in this model are not about the payment method, they are about silence.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>Handling Rate Limits Properly — Backoff, Retry-After, and the Bugs In Between</title>
      <dc:creator>mrnoyy</dc:creator>
      <pubDate>Thu, 10 Sep 2026 09:12:40 +0000</pubDate>
      <link>https://dev.to/mrnoyy/handling-rate-limits-properly-backoff-retry-after-and-the-bugs-in-between-55b2</link>
      <guid>https://dev.to/mrnoyy/handling-rate-limits-properly-backoff-retry-after-and-the-bugs-in-between-55b2</guid>
      <description>&lt;p&gt;Every integration eventually meets a 429. The first version of the fix is usually a &lt;code&gt;sleep(1000)&lt;/code&gt; between calls. Here is what actually holds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the response before you guess
&lt;/h2&gt;

&lt;p&gt;Most APIs tell you what they want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;429&lt;/span&gt; &lt;span class="ne"&gt;Too Many Requests&lt;/span&gt;
&lt;span class="na"&gt;Retry-After&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30&lt;/span&gt;
&lt;span class="na"&gt;X-RateLimit-Remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="na"&gt;X-RateLimit-Reset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1643723400&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check those headers on every response, not just on 429s. &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt; dropping toward zero is your signal to slow down &lt;em&gt;before&lt;/em&gt; you get blocked. Reacting only to failures means you are always one failure behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry-After has two legal formats
&lt;/h2&gt;

&lt;p&gt;RFC 7231 allows both a delay in seconds and an HTTP-date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;Retry-After&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30&lt;/span&gt;
&lt;span class="py"&gt;Retry-After&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fri, 31 Jul 2026 07:00:00 GMT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;parseInt&lt;/code&gt; on the second returns &lt;code&gt;31&lt;/code&gt;, which is not thirty-one seconds — it is the day of the month. Your retry fires early, hits the limit again, and you burn attempts.&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;retryAfterMs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fallbackMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30000&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;header&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;fallbackMs&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&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="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&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;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Number&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;when&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;raw&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;when&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;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;when&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;now&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;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fallbackMs&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;fallbackMs&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;Note the &lt;code&gt;diff &amp;gt; 0&lt;/code&gt; check. A clock skew between your machine and the server can produce a date already in the past, and a negative timeout means you retry instantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backoff for everything else
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Retry-After&lt;/code&gt; is authoritative when present. When it is not — or for 500s and 502s — exponential backoff with a cap:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;waitMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add jitter if more than one worker is retrying. Without it, every client that failed at the same moment retries at the same moment, and you have rebuilt the stampede you were avoiding.&lt;/p&gt;

&lt;p&gt;Cap the attempts. An unbounded retry loop against an API that is down is a job that never ends and a bill that keeps growing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate the retryable from the fatal
&lt;/h2&gt;

&lt;p&gt;This is the part people skip, and it is the one that causes real damage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;429, 502, 503, 504&lt;/strong&gt; — retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;401, 403&lt;/strong&gt; — do not retry. Your key is wrong or revoked. Retrying will not fix it and some providers count repeated auth failures against you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;400, 422&lt;/strong&gt; — do not retry. The payload is malformed. It will be malformed the second time too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A loop that retries a 422 will send the same broken request five times and then report a rate limit problem you do not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency is the actual cause
&lt;/h2&gt;

&lt;p&gt;Most 429s come from &lt;code&gt;Promise.all&lt;/code&gt; over an array. Ten items, ten simultaneous requests, and no limit anywhere:&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;// this is a rate limit waiting to happen&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&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="nx"&gt;ids&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;id&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;fetchItem&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Promise.all&lt;/code&gt; has no concurrency control. For small batches, a sequential loop with a delay is honest and easy to reason about:&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="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;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchItem&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delayMs&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 larger ones, a worker pool of three to five is usually plenty. The throughput difference against a rate-limited API is smaller than you expect, because the limit is the ceiling either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not fetch the same thing twice
&lt;/h2&gt;

&lt;p&gt;The cheapest request is the one you skip. Cache responses that do not change within a run, and keep a local record of what you already did between runs. In my publishing pipeline a content hash decides whether a post needs an update at all — most runs make zero write calls because nothing changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the waiting visible
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`429 — waiting &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;waitMs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;s`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A CI job that sits silent for ninety seconds looks hung. One line of output turns a support question into a shrug.&lt;/p&gt;




&lt;p&gt;None of this is complicated. It is just that the naive version works fine in testing with three items and fails on the first real batch, which is exactly when you are least in the mood to debug it.&lt;/p&gt;

</description>
      <category>api</category>
      <category>node</category>
      <category>javascript</category>
      <category>backend</category>
    </item>
    <item>
      <title>Writing a Frontmatter Parser in 60 Lines Instead of Adding a Dependency</title>
      <dc:creator>mrnoyy</dc:creator>
      <pubDate>Tue, 08 Sep 2026 09:13:11 +0000</pubDate>
      <link>https://dev.to/mrnoyy/writing-a-frontmatter-parser-in-60-lines-instead-of-adding-a-dependency-45jc</link>
      <guid>https://dev.to/mrnoyy/writing-a-frontmatter-parser-in-60-lines-instead-of-adding-a-dependency-45jc</guid>
      <description>&lt;p&gt;Most markdown pipelines pull in a YAML library to read the block at the top of the file. If your frontmatter is a handful of strings, a boolean, and a list of tags, you can skip it. Here is the version I use, and the parts I got wrong first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the block actually contains
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Post"&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;webdev&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;node&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://example.com/blog/my-post&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strings, one array, one boolean. That is a tiny subset of YAML. Real YAML has anchors, multi-line scalars, nested maps, and a type-coercion table that has surprised people for years. None of it is in my files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Splitting the file
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&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;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^---&lt;/span&gt;&lt;span class="se"&gt;\r?\n([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)\r?\n&lt;/span&gt;&lt;span class="sr"&gt;---&lt;/span&gt;&lt;span class="se"&gt;\r?\n?([\s\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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that regex, each learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;\r?\n&lt;/code&gt; everywhere, because a file edited on Windows and committed without normalization has CRLF line endings and a &lt;code&gt;\n&lt;/code&gt;-only pattern silently fails to match.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[\s\S]*?&lt;/code&gt; non-greedy, so a &lt;code&gt;---&lt;/code&gt; horizontal rule later in the body does not become the closing delimiter.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;^&lt;/code&gt; anchor, so a &lt;code&gt;---&lt;/code&gt; in the middle of a document is not mistaken for an opening block.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And before any of that:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&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;\u&lt;/span&gt;&lt;span class="sr"&gt;FEFF/&lt;/span&gt;&lt;span class="p"&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;A byte order mark at the start of the file means the first character is not &lt;code&gt;-&lt;/code&gt;, the regex does not match, and the error message says the frontmatter is missing when you can see it right there. That one cost me a genuinely embarrassing amount of time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing the keys
&lt;/h2&gt;

&lt;p&gt;Line by line, with one piece of state for list continuation:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseFrontmatterBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;block&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;out&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;currentKey&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;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;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r?\n&lt;/span&gt;&lt;span class="sr"&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;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;line&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;continue&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;dashItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&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;*-&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="se"&gt;)&lt;/span&gt;&lt;span class="sr"&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="nx"&gt;dashItem&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;currentKey&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="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentKey&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentKey&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="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentKey&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="nf"&gt;unquote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dashItem&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="k"&gt;continue&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;kv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&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-Za-z0-9_&lt;/span&gt;&lt;span class="se"&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;*:&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="se"&gt;)&lt;/span&gt;&lt;span class="sr"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;currentKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key&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;rawValue&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="nx"&gt;value&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="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;out&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;=&lt;/span&gt; &lt;span class="p"&gt;[];&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="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;value&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="s1"&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="nx"&gt;out&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;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="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="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&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;v&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;unquote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&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="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;false&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="nx"&gt;out&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;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&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="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;out&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;=&lt;/span&gt; &lt;span class="nf"&gt;unquote&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="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;out&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 &lt;code&gt;dashItem&lt;/code&gt; branch has to come first. A dashed list item like &lt;code&gt;- something: else&lt;/code&gt; also matches the key-value pattern, and if you check that one first you get a key named &lt;code&gt;- something&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The URL trap
&lt;/h2&gt;

&lt;p&gt;The obvious key-value regex is &lt;code&gt;/^(\w+):\s*(.*)$/&lt;/code&gt;. Now parse this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://example.com/blog/post&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Split on the first colon and you are fine. Split on every colon, or use a lazy value group, and you get &lt;code&gt;https&lt;/code&gt; as the value. Match the key against &lt;code&gt;[A-Za-z0-9_]+&lt;/code&gt; explicitly and take the rest of the line as the value, whatever is in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate at the boundary
&lt;/h2&gt;

&lt;p&gt;Parsing is the cheap part. The value comes from failing loudly:&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="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;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&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;basename&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: "title" is required`&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;body&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&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;basename&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: body is empty`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A file with a typo in a key name should stop the run, not publish an untitled post. My CI runs a &lt;code&gt;check&lt;/code&gt; command over every file before anything touches the network, so a bad file fails in two seconds instead of halfway through a batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to just use the library
&lt;/h2&gt;

&lt;p&gt;Reach for a real parser the moment you need nested objects, multi-line values, dates as actual &lt;code&gt;Date&lt;/code&gt; objects, or files written by people who did not write the parser. Sixty lines is worth it when you control both ends and the schema is five fields. It is not worth it as a matter of principle.&lt;/p&gt;




&lt;p&gt;The point is not that dependencies are bad. It is that "read five known keys from a fenced block" is a smaller problem than "parse YAML", and the small problem has a small solution.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>markdown</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Next.js Static Export for Content Sites — the Settings That Actually Matter</title>
      <dc:creator>mrnoyy</dc:creator>
      <pubDate>Thu, 03 Sep 2026 09:12:45 +0000</pubDate>
      <link>https://dev.to/mrnoyy/nextjs-static-export-for-content-sites-the-settings-that-actually-matter-2jn0</link>
      <guid>https://dev.to/mrnoyy/nextjs-static-export-for-content-sites-the-settings-that-actually-matter-2jn0</guid>
      <description>&lt;p&gt;I run a set of content-heavy sites on Next.js with &lt;code&gt;output: 'export'&lt;/code&gt;. No server, no serverless functions, just HTML on a CDN. It is fast and cheap, but the defaults are tuned for the server runtime and a few of them will quietly cost you traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config I end up with every time
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** @type {import('next').NextConfig} */&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;export&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;trailingSlash&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="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;unoptimized&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines, and each one solves a real problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  trailingSlash
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;output: 'export'&lt;/code&gt;, a route like &lt;code&gt;/reviews/example&lt;/code&gt; is written to disk. Without &lt;code&gt;trailingSlash&lt;/code&gt;, you get &lt;code&gt;reviews/example.html&lt;/code&gt;. With it, you get &lt;code&gt;reviews/example/index.html&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why that matters: most static hosts serve &lt;code&gt;/reviews/example/&lt;/code&gt; from the directory form without a redirect, but serve &lt;code&gt;/reviews/example&lt;/code&gt; by redirecting first. If half your internal links point one way and your sitemap points the other, every crawl spends requests on 301s before reaching content. Pick one form, set it in config, and make your sitemap match exactly.&lt;/p&gt;

&lt;p&gt;Also check what your host does by default. Some normalize trailing slashes, some do not, and finding out after launch means a wave of redirect chains.&lt;/p&gt;

&lt;h3&gt;
  
  
  images: unoptimized
&lt;/h3&gt;

&lt;p&gt;The default Image Optimization API needs a running server. With a static export it either fails the build or, on some hosts, silently falls back to an optimizer you did not intend to use.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unoptimized: true&lt;/code&gt; makes &lt;code&gt;next/image&lt;/code&gt; emit a plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; with the width, height, and lazy loading attributes intact. You still get the layout-shift protection, which is the part that affects Core Web Vitals. You lose automatic resizing, so generate the sizes you need at build time — or accept the original file size, but then actually check what you are shipping. A 2MB hero image will undo everything else on this list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dynamic routes need generateStaticParams
&lt;/h2&gt;

&lt;p&gt;Every dynamic segment must enumerate its paths at build time:&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="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;generateStaticParams&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;posts&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;getAllPosts&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;posts&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;post&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="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss one and that page simply is not in your output. It is not a 404 you will see in dev — dev renders on demand and looks perfect. You find out from the live site or from a crawl.&lt;/p&gt;

&lt;p&gt;I now diff the built &lt;code&gt;out/&lt;/code&gt; directory against my content source as a build step. A page count mismatch fails the build. That check has caught two silent drops for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sitemap problem
&lt;/h2&gt;

&lt;p&gt;Static export does not generate a sitemap. Plenty of tutorials hand you a &lt;code&gt;sitemap.xml&lt;/code&gt; route handler, which works on the server runtime and does nothing here.&lt;/p&gt;

&lt;p&gt;Two options that do work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A Node script after the build that walks &lt;code&gt;out/&lt;/code&gt; and writes the XML from the actual files on disk.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next-sitemap&lt;/code&gt; as a &lt;code&gt;postbuild&lt;/code&gt; script.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I prefer the first, because it reflects what was really exported rather than what the config says should exist. If &lt;code&gt;generateStaticParams&lt;/code&gt; dropped a page, a config-driven sitemap still lists it and you send crawlers to a 404. A disk-driven sitemap cannot lie to you.&lt;/p&gt;

&lt;p&gt;Same for &lt;code&gt;robots.txt&lt;/code&gt; — put it in &lt;code&gt;public/&lt;/code&gt; and it gets copied verbatim.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;Be honest about this before choosing static export:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No middleware, no rewrites, no server-side redirects. Redirects move to your host's config, and every host spells it differently.&lt;/li&gt;
&lt;li&gt;No ISR. Any content change means a rebuild and redeploy.&lt;/li&gt;
&lt;li&gt;No route handlers. Forms need a third-party endpoint or an external function.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a marketing site, docs, or a review site that changes a few times a week, none of that hurts. For anything with per-user content, you want the server runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build times
&lt;/h2&gt;

&lt;p&gt;Static export builds every page every time. At a few hundred pages that is fine. Past a couple of thousand it becomes the slow part of your day.&lt;/p&gt;

&lt;p&gt;What helped most was caching the data-fetching layer. If your pages come from an API or a database, fetch once into a local JSON cache and let the build read from that. The rendering is rarely the bottleneck — the network round trips are.&lt;/p&gt;




&lt;p&gt;None of this is exotic. It is just that the export path and the server path share a framework and not a set of assumptions, and the docs cover both without always telling you which one you are reading about.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
      <category>staticsite</category>
    </item>
    <item>
      <title>Publishing to Dev.to from GitHub Actions with Zero Dependencies</title>
      <dc:creator>mrnoyy</dc:creator>
      <pubDate>Wed, 26 Aug 2026 07:30:34 +0000</pubDate>
      <link>https://dev.to/mrnoyy/publishing-to-devto-from-github-actions-with-zero-dependencies-4gj6</link>
      <guid>https://dev.to/mrnoyy/publishing-to-devto-from-github-actions-with-zero-dependencies-4gj6</guid>
      <description>&lt;p&gt;I write posts in markdown, commit them, and they show up on Dev.to. No dashboard, no copy-paste, no npm install. Here is the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why zero dependencies
&lt;/h2&gt;

&lt;p&gt;The pipeline does three things: read a file, hash it, send JSON over HTTP. Node 18+ has &lt;code&gt;fs&lt;/code&gt;, &lt;code&gt;crypto&lt;/code&gt;, and &lt;code&gt;fetch&lt;/code&gt; built in. Adding &lt;code&gt;axios&lt;/code&gt;, &lt;code&gt;gray-matter&lt;/code&gt;, and &lt;code&gt;dotenv&lt;/code&gt; would mean a &lt;code&gt;node_modules&lt;/code&gt; restore on every CI run to save maybe forty lines of code.&lt;/p&gt;

&lt;p&gt;My whole runner step is now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;22'&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node src/index.js publish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No install step at all. The job finishes in about fifteen seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things that broke first
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The auth header is not Bearer
&lt;/h3&gt;

&lt;p&gt;Almost every API I had touched before uses &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. Dev.to does not. The key goes in a header literally named &lt;code&gt;api-key&lt;/code&gt;:&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="nx"&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="s1"&gt;api-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/vnd.forem.api-v1+json&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;p&gt;I lost twenty minutes to 401s before reading the docs properly rather than assuming.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Retry-After comes back in two different formats
&lt;/h3&gt;

&lt;p&gt;When you hit the rate limit you get a 429 with a &lt;code&gt;Retry-After&lt;/code&gt; header. Sometimes it is a number of seconds. Sometimes it is a full HTTP-date string like &lt;code&gt;Fri, 31 Jul 2026 07:00:00 GMT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My first implementation was &lt;code&gt;parseInt(header, 10)&lt;/code&gt;. On the date format that returns &lt;code&gt;31&lt;/code&gt;, so the retry fires half a minute later, hits the limit again, and the loop burns through its attempts for nothing.&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;retryAfterMs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fallbackMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30000&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;header&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;fallbackMs&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&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="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&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;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Number&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;when&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;raw&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;when&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;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;when&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;now&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;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fallbackMs&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;fallbackMs&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;Handle both, and always keep a fallback for the case where the header is missing or garbage.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Tags are sanitized silently
&lt;/h3&gt;

&lt;p&gt;Dev.to strips everything that is not a lowercase letter or a digit. &lt;code&gt;next-js&lt;/code&gt; becomes &lt;code&gt;nextjs&lt;/code&gt;. You get a 200 back and no warning at all.&lt;/p&gt;

&lt;p&gt;That matters if you hash your content to decide whether to update a post, like I do. The local file says &lt;code&gt;next-js&lt;/code&gt;, the remote says &lt;code&gt;nextjs&lt;/code&gt;, so the hash never matches and every run pushes a pointless update. Normalize before hashing, not after:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalizeTags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&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="nx"&gt;tags&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;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="nc"&gt;String&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="nf"&gt;toLowerCase&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;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four tags is the ceiling. The fifth is dropped, again silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not creating duplicates
&lt;/h2&gt;

&lt;p&gt;The API has no idempotency key, so &lt;code&gt;POST /articles&lt;/code&gt; twice gives you two articles. I keep a small state file mapping slug to article id and a content hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"my-post"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1234567&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://dev.to/user/my-post-abcd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3f9a1c2b7e04"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New slug means &lt;code&gt;POST&lt;/code&gt;. Known slug with a changed hash means &lt;code&gt;PUT /articles/:id&lt;/code&gt;. Known slug, same hash, do nothing. The workflow commits that file back to the repo after each run, with &lt;code&gt;contents: write&lt;/code&gt; permission and a path filter on the trigger so the commit does not retrigger the workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Canonical URLs
&lt;/h2&gt;

&lt;p&gt;This is the part that actually matters if you have your own blog. Every cross-post should carry &lt;code&gt;canonical_url&lt;/code&gt; pointing back to your original. Without it you have published the same article on a domain with far more authority than yours, and search engines will pick that one.&lt;/p&gt;

&lt;p&gt;I derive it from the filename so I cannot forget:&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="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;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canonical_url&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;CANONICAL_BASE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canonical_url&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;CANONICAL_BASE&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;post&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the script warns loudly on any post that ends up without one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dry run first
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--dry-run&lt;/code&gt; prints exactly what would be sent, resolved tags and canonical included, and touches nothing. Every schema change I make gets a dry run before it gets an API call. It has caught more mistakes than any test I wrote.&lt;/p&gt;




&lt;p&gt;The whole thing is about 250 lines across three files. If you cross-post to more than one platform, the same shape works — the state file just grows a key per platform.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>node</category>
      <category>api</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
