<?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: Esmeralda Sánchez</title>
    <description>The latest articles on DEV Community by Esmeralda Sánchez (@esmesm).</description>
    <link>https://dev.to/esmesm</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%2F3845404%2Fcb5b7940-df06-4c52-86cb-fbac017406a3.webp</url>
      <title>DEV Community: Esmeralda Sánchez</title>
      <link>https://dev.to/esmesm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/esmesm"/>
    <language>en</language>
    <item>
      <title>Deploying fully static Next.js websites on Vercel</title>
      <dc:creator>Esmeralda Sánchez</dc:creator>
      <pubDate>Sun, 02 Aug 2026 12:51:08 +0000</pubDate>
      <link>https://dev.to/esmesm/deploying-fully-static-nextjs-websites-on-vercel-47hi</link>
      <guid>https://dev.to/esmesm/deploying-fully-static-nextjs-websites-on-vercel-47hi</guid>
      <description>&lt;p&gt;Static site generation has a branding problem. Say "static site" and people picture a blog with twelve posts and a contact form. So how far can you actually push it before you need a backend?&lt;/p&gt;

&lt;p&gt;Further than most people assume. This is a walkthrough of a production site that has no database, no API layer, no user accounts and no server-side state, and still ships 232 prerendered pages with per-user results, shareable links and dynamic social cards.&lt;/p&gt;

&lt;p&gt;The site is a Spanish political test with nine ideological axes, seventeen parties, fifty-four questions. It is in Spanish, but nothing here depends on reading it. Treat it as the reference implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture in one sentence
&lt;/h2&gt;

&lt;p&gt;Three data files are the source of truth, everything else is derived at build time, and everything user-specific happens in the browser.&lt;/p&gt;

&lt;p&gt;That is the whole trick. The rest is consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Derive pages, don't author them
&lt;/h2&gt;

&lt;p&gt;The site has 232 URLs. Almost none of them were written by hand.&lt;/p&gt;

&lt;p&gt;There are three data modules: the axes, the parties, and the questions. From those, &lt;code&gt;generateStaticParams&lt;/code&gt; produces every content route:&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;// app/ejes/[id]/page.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;generateStaticParams&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;AXES&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;a&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;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting one is the comparison pages. Seventeen parties means 17 × 16 / 2 = 136 unique pairs, and each pair gets its own page, its own metadata and its own canonical URL:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;allPairs&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="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;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;PARTIES&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="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;let&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;PARTIES&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="nx"&gt;j&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PARTIES&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PARTIES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&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;return&lt;/span&gt; &lt;span class="nx"&gt;out&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;generateStaticParams&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="nf"&gt;allPairs&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="na"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pairSlug&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;a&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;b&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;136 pages from twelve lines. And because the page body is computed from the same vectors, &lt;strong&gt;recalibrating one party silently rewrites the sixteen pages that involve it&lt;/strong&gt;. No CMS, no migration, no content drift. The numbers on the page cannot disagree with the model, because they &lt;em&gt;are&lt;/em&gt; the model.&lt;/p&gt;

&lt;p&gt;One detail worth stealing: normalise the slug order.&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;// pp-vs-psoe and psoe-vs-pp must not become two URLs with identical content&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;pairSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&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;b&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="kd"&gt;const&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="nx"&gt;y&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;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&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;PARTIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&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;=&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;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;PARTIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&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;=&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;n&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="s2"&gt;`&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="s2"&gt;-vs-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;y&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both spellings resolve, but the canonical tag always points at the ordered one. Combinatorial routes are a duplicate-content generator if you skip this.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Do the computation in the browser
&lt;/h2&gt;

&lt;p&gt;The test scores nine axes, ranks seventeen parties by weighted affinity, computes a conviction score from mirror questions and an affective-distance score, and renders five visualisations. None of that touches a server.&lt;/p&gt;

&lt;p&gt;It is a few hundred lines of pure functions over an array of numbers. There is no reason to pay a network round trip for it, and no reason to store it.&lt;/p&gt;

&lt;p&gt;The consequence people underestimate: &lt;strong&gt;if the computation is client-side, the data never has to exist anywhere.&lt;/strong&gt; No database means no schema, no migrations, no connection pooling, no GDPR data-processing chapter, no breach surface, no backup story, no per-request cost. All of that complexity was never load-bearing. It was there to move numbers to a place where the same arithmetic could happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Put the state in the URL
&lt;/h2&gt;

&lt;p&gt;Results still need to be shareable. The move is to encode the answers into the URL instead of storing them.&lt;/p&gt;

&lt;p&gt;Fifty-four answers on a five-point Likert scale need three bits each. Forty-two ideological answers pack into sixteen bytes, which is about twenty-two characters of base64url:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encodeAnswers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&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="kr"&gt;string&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;bits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;answers&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;a&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;a&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="c1"&gt;// -2..+2  -&amp;gt;  0..4&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&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;Uint8Array&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;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bits&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;3&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&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="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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byte&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bit&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bit&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
    &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;byte&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;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xff&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;shift&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;byte&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="o"&gt;|=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;shift&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="nf"&gt;toBase64Url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&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;Now &lt;code&gt;/resultado?a=BQkTAg...&lt;/code&gt; is a complete, self-contained result. Share it, bookmark it, send it to a friend. Nothing was written anywhere. Working state lives in &lt;code&gt;sessionStorage&lt;/code&gt; and dies with the tab.&lt;/p&gt;

&lt;p&gt;One warning from experience: &lt;strong&gt;be strict when decoding.&lt;/strong&gt; If the payload is shorter than expected because it came from an older version with fewer questions, fail loudly instead of padding. A tolerant decoder silently produces a wrong result, which is far worse than an error page.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Where the edge runtime earns its keep
&lt;/h2&gt;

&lt;p&gt;There is exactly one dynamic route on the whole site, and it exists for a good reason: the social card for a shared result has to render &lt;em&gt;that person's&lt;/em&gt; result.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next/og&lt;/code&gt; on the edge runtime does this in a few milliseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;edge&lt;/span&gt;&lt;span class="dl"&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;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;searchParams&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="s1"&gt;a&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="nx"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeAxes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&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;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Card&lt;/span&gt; &lt;span class="na"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the correct shape for "static by default": everything prerendered, one narrow dynamic endpoint where personalisation genuinely requires it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The deployment traps
&lt;/h2&gt;

&lt;p&gt;This is the part I actually want to hand over, because each of these cost real hours and none of them announce themselves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Social crawlers get blocked on preview URLs.&lt;/strong&gt; Vercel's bot protection sits in front of preview and deployment URLs. Result: X, Telegram, WhatsApp and Bluesky crawlers never reached the OG endpoint and every share rendered blank, while everything looked perfect in local testing and in the card validators you were pasting the production URL into. The fix was to pre-generate static PNGs at build time for every page that doesn't need personalisation, and serve them from the CDN with no gate in front:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// package.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"scripts"&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;"og:generate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsx scripts/generate-og.tsx"&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;Fourteen PNGs, three seconds, and the dynamic endpoint stays for the one route that needs it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;metadataBase&lt;/code&gt; must be your canonical domain.&lt;/strong&gt; The tempting line is:&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;// wrong&lt;/span&gt;
&lt;span class="nx"&gt;metadataBase&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://&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;VERCEL_URL&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;VERCEL_URL&lt;/code&gt; is the hashed per-deployment hostname. Your OG tags then point at &lt;code&gt;yoursite-a1b2c3-team.vercel.app&lt;/code&gt;, which is exactly the host that bot protection blocks. Hardcode the real domain and allow an env override for local work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Satori does not eat your fonts.&lt;/strong&gt; The image renderer behind &lt;code&gt;next/og&lt;/code&gt; supports TTF and OTF only. WOFF2 fails, and variable fonts break &lt;code&gt;opentype.js&lt;/code&gt; at &lt;code&gt;parseFvarTable&lt;/code&gt;. If you use &lt;code&gt;next/font&lt;/code&gt; with variable families (you probably do), you cannot reuse them in OG generation. Ship a static TTF alongside, or use the built-in font and design around it. Also watch your glyphs: arrows like &lt;code&gt;→&lt;/code&gt; and &lt;code&gt;↔&lt;/code&gt; render as tofu in many static TTFs. Use ASCII.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't run &lt;code&gt;next build&lt;/code&gt; while the dev server is up.&lt;/strong&gt; They share &lt;code&gt;.next/&lt;/code&gt; and stomp on each other's chunks, and the error you get is &lt;code&gt;Cannot find module './xyz.js'&lt;/code&gt;, which sends you hunting for an import problem that does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Structured data: only two types do anything
&lt;/h2&gt;

&lt;p&gt;Static generation makes it trivial to emit JSON-LD everywhere, which tempts you to emit all of it. Most of it does nothing visible.&lt;/p&gt;

&lt;p&gt;As of mid-2026, the two that produce a visible change in Google's result are &lt;code&gt;BreadcrumbList&lt;/code&gt; (replaces the URL line with your site path) and &lt;code&gt;Article&lt;/code&gt;. &lt;code&gt;FAQPage&lt;/code&gt; was deprecated and retired. &lt;code&gt;ItemList&lt;/code&gt;, &lt;code&gt;DefinedTerm&lt;/code&gt; and the domain-specific types do help an engine understand the entity, but they will not render anything.&lt;/p&gt;

&lt;p&gt;Two things worth getting right:&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;// The current page does not link to itself: omit `item` on the last crumb.&lt;/span&gt;
&lt;span class="nx"&gt;itemListElement&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;c&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="p"&gt;({&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@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;ListItem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&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="na"&gt;name&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="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;i&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="nx"&gt;length&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="p"&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;item&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;SITE&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="nx"&gt;path&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 declare your &lt;code&gt;Organization&lt;/code&gt; once in the root layout with a stable &lt;code&gt;@id&lt;/code&gt;, then reference it by &lt;code&gt;@id&lt;/code&gt; from every &lt;code&gt;Article&lt;/code&gt; instead of repeating the object. One entity, many references.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Third-party requests: zero
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next/font&lt;/code&gt; self-hosts Google Fonts at build time, so the page makes no request to &lt;code&gt;fonts.googleapis.com&lt;/code&gt;. No CDN scripts, no tag manager, no analytics pixel. Combined with client-side computation, the network tab on a page load shows your own domain and nothing else.&lt;/p&gt;

&lt;p&gt;That is a privacy claim any reader can verify in ten seconds with devtools, which is worth more than a paragraph in a policy page.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Where this stops working
&lt;/h2&gt;

&lt;p&gt;Being honest about the boundary is the point of the exercise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content that changes between builds.&lt;/strong&gt; Prices, stock, live scores. You need ISR or a real fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything multi-user.&lt;/strong&gt; Shared state between two people is a database, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth.&lt;/strong&gt; Client-side computation means the client can compute anything. Fine when the data is the user's own answers. Not fine when it's someone else's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Very large combinatorial spaces.&lt;/strong&gt; 136 pages is nothing. 136,000 makes build time your problem, and that is where ISR or on-demand generation starts to pay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The heuristic I'd give: &lt;strong&gt;if the server would only be reformatting data the client already has, you don't need the server.&lt;/strong&gt; Test scoring, pricing calculators, configurators, quizzes, converters, most dashboards over static datasets. Very often the backend exists because the framework tutorial had one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Author data, generate pages. 136 comparison pages from twelve lines of loop.&lt;/li&gt;
&lt;li&gt;Normalise combinatorial slugs or you have built a duplicate-content machine.&lt;/li&gt;
&lt;li&gt;Compute in the browser and the data never needs to exist.&lt;/li&gt;
&lt;li&gt;Put shareable state in the URL, and decode strictly.&lt;/li&gt;
&lt;li&gt;Keep one dynamic endpoint for the thing that genuinely varies per user.&lt;/li&gt;
&lt;li&gt;Budget an afternoon for OG images. They will not work the first time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The working example is at &lt;a href="https://testpolitica.com" rel="noopener noreferrer"&gt;testpolitica.com&lt;/a&gt;, and the piece of it that best shows the derivation trick is the &lt;a href="https://testpolitica.com/ejes" rel="noopener noreferrer"&gt;nine axes index&lt;/a&gt;: every one of those pages, and the 136 party comparisons behind them, is a function of the same three files.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>nextjs</category>
      <category>seo</category>
    </item>
    <item>
      <title>Using Claude to decrypt Carmageddon</title>
      <dc:creator>Esmeralda Sánchez</dc:creator>
      <pubDate>Sun, 26 Jul 2026 23:14:49 +0000</pubDate>
      <link>https://dev.to/esmesm/using-claude-to-decrypt-carmageddon-dkk</link>
      <guid>https://dev.to/esmesm/using-claude-to-decrypt-carmageddon-dkk</guid>
      <description>&lt;p&gt;I used to play Carmageddon with &lt;a class="mentioned-user" href="https://dev.to/edulazaro"&gt;@edulazaro&lt;/a&gt; and we decided to play it again now that the new one is coming this year.&lt;/p&gt;

&lt;p&gt;We also tried to add new cars, and we wanted their numbers to sit in a sane range: mass, top speed, suspension, etc.&lt;/p&gt;

&lt;p&gt;The 1997 original is the obvious reference. But there is one problem. Carmageddon stores all of that &lt;strong&gt;encrypted&lt;/strong&gt; on disk. So for example &lt;code&gt;CARS/BLKEAGLE.TXT&lt;/code&gt; is just encrypted garbage.&lt;/p&gt;

&lt;p&gt;So at first just asked Claude to work out the cipher, but it didn't work. We fed it encrypted files expecting frequency analysis, patterns, a simple XOR. Nothing worked.&lt;/p&gt;

&lt;p&gt;Thre was a 16-byte key, an index starting at &lt;code&gt;length % 16&lt;/code&gt; and advancing by 7, tabs remapped to &lt;code&gt;0x9f&lt;/code&gt;, and a second key that kicks in as soon as a &lt;code&gt;//&lt;/code&gt; comment appears. You don't guess that from staring at bytes. The thing is that when you ask an LLM to guess something that can only be known, it will hand you something wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What worked
&lt;/h2&gt;

&lt;p&gt;Found the repo &lt;a href="https://github.com/dethrace-labs/dethrace" rel="noopener noreferrer"&gt;dethrace&lt;/a&gt; exists, a decompilation of the game. The real code is in there. So I changed the question from "guess this" to "find this and explain it to me". So Claude located the &lt;code&gt;EncodeLine&lt;/code&gt; in &lt;code&gt;src/DETHRACE/common/utility.c&lt;/code&gt; and translated the C logic to JavaScript.&lt;/p&gt;

&lt;p&gt;But all attempts where bugged. The &lt;a href="https://es.wikipedia.org/wiki/Endianness" rel="noopener noreferrer"&gt;endianness&lt;/a&gt; of the key, and what settled it was a comment in &lt;code&gt;utility.c:125&lt;/code&gt; saying &lt;code&gt;GENERAL.TXT&lt;/code&gt; must decrypt to &lt;code&gt;0.01&lt;/code&gt;. Big-endian produced that, little-endian didn't. Without a known value to check against, we'd still be picking between equally convincing variants.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still struggling
&lt;/h2&gt;

&lt;p&gt;With all 41 cars decrypted, We wrote an extractor that read fields by position: line 12 is the mass, line 15 is the gear count, etc. But the numbers came out absurd. A car with a top speed of 4 and 200 gears.&lt;/p&gt;

&lt;p&gt;The files come inthree format versions (v2, v3, v4) with different fields, so reading positionally shifts the columns in some files and not others. The worst part is that it doesn't fail. It just gives you numbers, and some of them even look reasonable. The fix was to locate each value by its comment text, which you more or less know in advance, since every line carries its &lt;code&gt;// mass in tonnes&lt;/code&gt;, etc, and seems stable across versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  And that's it
&lt;/h2&gt;

&lt;p&gt;In genreal, it seems very useful in decryption to find a known value before writing the code, as that &lt;code&gt;0.01&lt;/code&gt; was worth more than&lt;br&gt;
any amount of reasoning about endianness.&lt;/p&gt;

&lt;p&gt;Here is the &lt;a href="https://github.com/edulazaro/carmageddon-extractor" rel="noopener noreferrer"&gt;extractor&lt;/a&gt; in plain Node/JS.&lt;/p&gt;

&lt;p&gt;And that's it 😊&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>coding</category>
      <category>programming</category>
    </item>
    <item>
      <title>A Real Time Information Using Andorra's Government APIs</title>
      <dc:creator>Esmeralda Sánchez</dc:creator>
      <pubDate>Fri, 27 Mar 2026 01:21:34 +0000</pubDate>
      <link>https://dev.to/esmesm/a-real-time-information-using-andorras-government-apis-1ngn</link>
      <guid>https://dev.to/esmesm/a-real-time-information-using-andorras-government-apis-1ngn</guid>
      <description>&lt;p&gt;For those who don't know it, Andorra is a tiny country of 80k people located in the Pyrenees. I've been living here for 3 years and built a guide for people moving here.&lt;/p&gt;

&lt;p&gt;The downside of living here? Snowstorms and traffic jams. Andorra only has two road entrances, so when there's heavy snow or a holiday weekend, it's chaos. You need to know which roads are clear, whether snowplows are out, and if there's an alternative route.&lt;/p&gt;

&lt;p&gt;Turns out the Andorran government has quite good public APIs. No keys or auth, so I used them to make my life easier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Built this with Next.js on Vercel. Each API route caches for 3 minutes so I don't overload the government APIs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cache&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;CACHE_TTL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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="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;GET&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="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;CACHE_TTL&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&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;data&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;fetch&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;MOBILITAT_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/cameras`&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;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;cache&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timestamp&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="nf"&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;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;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&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 OpenStreetMap data (supermarkets, banks, pharmacies...) the API has aggressive rate limiting, so I scrape 3 categories per run via GitHub Actions and store in Upstash Redis, so I don't need to pay for a server.&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.amazonaws.com%2Fuploads%2Farticles%2F6j275kjgt5qj49z5qejj.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F6j275kjgt5qj49z5qejj.webp" alt="Duty pharmacies map" width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A small issue is that Pharmacy shifts change at 9:30, not midnight, so if someone checks at 8AM, yesterday's pharmacy is still displayed for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoints
&lt;/h2&gt;

&lt;p&gt;These are the endpoints I used:&lt;/p&gt;

&lt;h3&gt;
  
  
  Mobilitat (that, is, traffic)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;app.mobilitat.ad/api/v1/&lt;/code&gt; gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GET &lt;code&gt;/cameras&lt;/code&gt;: Dozens oflive traffic cameras (animated GIFs!)&lt;/li&gt;
&lt;li&gt;GET &lt;code&gt;/incidents&lt;/code&gt;: Active road incidents&lt;/li&gt;
&lt;li&gt;GET &lt;code&gt;/llevaneus&lt;/code&gt;: Snowplows with real-time GPS + speed&lt;/li&gt;
&lt;li&gt;GET &lt;code&gt;/nevadesactive&lt;/code&gt;: Snow alert active? true/false&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The snowplow endpoint returns the exact position, heading and speed of every active snowplow:&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;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;42.5436866&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.733995&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"heading"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"speed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;19&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;So now I can check if the snowplows are out before leaving home in the morning, since I live at 2000 meters and some days the road is impassable.&lt;/p&gt;

&lt;p&gt;The camera endpoints return GIFs and are quite heavy.&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.amazonaws.com%2Fuploads%2Farticles%2Fuiyvzry300skr7s0ntau.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fuiyvzry300skr7s0ntau.webp" alt="Traffic cameras" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Fuel Prices
&lt;/h3&gt;

&lt;p&gt;All 60 gas stations with official prices. Gas here is cheap (1.53 EUR/L) because Andorra's VAT is only 4.5%:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BP Whatever"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parish"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Canillo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fuels"&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;"fuel_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Gas 95"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.535&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;h3&gt;
  
  
  Ski Weather
&lt;/h3&gt;

&lt;p&gt;Free, no API key. You need to pass the resort's elevation to get accurate mountain weather:&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="err"&gt;GET https://api.open-meteo.com/v1/forecast
  ?latitude=42.57&amp;amp;longitude=1.66&amp;amp;elevation=1800
  &amp;amp;current=temperature_2m,snow_depth,wind_speed_10m
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The tools
&lt;/h2&gt;

&lt;p&gt;These are the tools I built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://andorranos.com/trafico-frontera" rel="noopener noreferrer"&gt;Border traffic&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://andorranos.com/precio-gasolina" rel="noopener noreferrer"&gt;Fuel prices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://andorranos.com/nieve-pistas" rel="noopener noreferrer"&gt;Snow &amp;amp; ski&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://andorranos.com/estado-carreteras" rel="noopener noreferrer"&gt;Snowplows and road alerts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://andorranos.com/farmacias-guardia" rel="noopener noreferrer"&gt;Duty pharmacy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;

</description>
      <category>api</category>
      <category>nextjs</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
