<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Abid Ali's Dev Notes]]></title><description><![CDATA[Abid Ali's Dev Notes]]></description><link>https://abidali671.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Abid Ali&apos;s Dev Notes</title><link>https://abidali671.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 19 Oct 2026 06:08:52 GMT</lastBuildDate><atom:link href="https://abidali671.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Turn One-Off Website Clients into Monthly Retainers: A Free Uptime, SSL and Speed Check with GitHub Actions]]></title><description><![CDATA[Most freelance website projects end the same way. You deliver, the client says thanks, the contract closes, and you never hear from them again until something breaks, at which point they're stressed a]]></description><link>https://abidali671.hashnode.dev/turn-one-off-website-clients-into-monthly-retainers-a-free-uptime-ssl-and-speed-check-with-github-actions</link><guid isPermaLink="true">https://abidali671.hashnode.dev/turn-one-off-website-clients-into-monthly-retainers-a-free-uptime-ssl-and-speed-check-with-github-actions</guid><category><![CDATA[Freelancing]]></category><category><![CDATA[GitHub Actions]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[Devops]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Sat, 17 Oct 2026 17:04:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/2a04a9a2-95a1-4f31-ae59-dab1073de5b8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most freelance website projects end the same way. You deliver, the client says thanks, the contract closes, and you never hear from them again until something breaks, at which point they're stressed and you're busy.</p>
<p>There's a better ending: a small monthly maintenance retainer. The client pays a modest fixed amount, and in return you keep an eye on the site and fix small things. For the client it's peace of mind. For you it's predictable income and a reason to stay in touch, which is how one-off clients turn into repeat clients.</p>
<p>The hard part is proving the value. "I'll keep an eye on it" is vague. A short monthly report that says "your site was checked every 30 minutes, it was up for all but one check, your SSL certificate renews in 41 days, and the homepage loads in this many milliseconds" is concrete.</p>
<p>This post builds exactly that, for free, using a GitHub repository, a scheduled GitHub Actions workflow and about 100 lines of Node.js. No servers to run, no paid monitoring service.</p>
<h2>What we'll check</h2>
<p>For each client site:</p>
<ol>
<li><strong>Is it up?</strong> HTTP status of one or more key URLs.</li>
<li><strong>How fast does it respond?</strong> Time to receive the response.</li>
<li><strong>When does the SSL certificate expire?</strong> Expired certificates take sites down with a scary browser warning, and they're completely preventable.</li>
<li><strong>Is the important content there?</strong> A keyword check, so a page that returns 200 but shows an error message still counts as down.</li>
</ol>
<p>Results go into a JSON file per day in the repo, and a second workflow turns a month of results into a Markdown report you can paste into an email.</p>
<h2>The repository layout</h2>
<pre><code class="language-text">site-checks/
  sites.json
  check.mjs
  report.mjs
  data/                  # results, committed by the workflow
  .github/workflows/
    check.yml
    report.yml
</code></pre>
<p><code>sites.json</code> lists what to check:</p>
<pre><code class="language-json">[
  {
    "client": "Bakery Co",
    "url": "https://bakery.example.com/",
    "expect": "Order online"
  },
  {
    "client": "Bakery Co",
    "url": "https://bakery.example.com/menu",
    "expect": "Menu"
  },
  {
    "client": "Clinic",
    "url": "https://clinic.example.org/",
    "expect": "Book an appointment"
  }
]
</code></pre>
<p>Use a <strong>private</strong> repository. Client domains and any notes about their setup aren't something to publish.</p>
<h2>The check script</h2>
<p>Node 20 and later have <code>fetch</code> built in, so the only imports are from the standard library:</p>
<pre><code class="language-js">// check.mjs
import { readFile, writeFile, mkdir } from "node:fs/promises";
import tls from "node:tls";

const sites = JSON.parse(await readFile("sites.json", "utf8"));

function certDaysLeft(hostname) {
  return new Promise((resolve) =&gt; {
    const socket = tls.connect(
      { host: hostname, port: 443, servername: hostname, timeout: 10_000 },
      () =&gt; {
        const cert = socket.getPeerCertificate();
        socket.end();
        if (!cert?.valid_to) return resolve(null);
        const ms = new Date(cert.valid_to).getTime() - Date.now();
        resolve(Math.floor(ms / 86_400_000));
      }
    );
    socket.on("error", () =&gt; resolve(null));
    socket.on("timeout", () =&gt; { socket.destroy(); resolve(null); });
  });
}

async function checkSite(site) {
  const started = performance.now();
  const result = { client: site.client, url: site.url, time: new Date().toISOString() };
  try {
    const res = await fetch(site.url, {
      redirect: "follow",
      signal: AbortSignal.timeout(15_000),
      headers: { "user-agent": "site-checks/1.0 (maintenance monitoring)" },
    });
    const body = await res.text();
    result.status = res.status;
    result.ms = Math.round(performance.now() - started);
    result.contentOk = site.expect ? body.includes(site.expect) : true;
    result.up = res.ok &amp;&amp; result.contentOk;
  } catch (err) {
    result.status = null;
    result.ms = null;
    result.up = false;
    result.error = err.name === "TimeoutError" ? "timeout" : err.message;
  }
  result.certDays = await certDaysLeft(new URL(site.url).hostname);
  return result;
}

const results = await Promise.all(sites.map(checkSite));

const day = new Date().toISOString().slice(0, 10);
await mkdir("data", { recursive: true });
const file = `data/${day}.json`;
let existing = [];
try { existing = JSON.parse(await readFile(file, "utf8")); } catch {}
await writeFile(file, JSON.stringify([...existing, ...results], null, 2));

const down = results.filter((r) =&gt; !r.up);
for (const r of results) {
  console.log(`${r.up ? "UP  " : "DOWN"} ${r.url} status=${r.status} ${r.ms ?? "-"}ms cert=${r.certDays ?? "?"}d`);
}
if (down.length) process.exitCode = 1; // makes the workflow run fail, so GitHub notifies you
</code></pre>
<p>A few deliberate choices:</p>
<ul>
<li><strong>The response time includes reading the body.</strong> It's a rough "how long did the page take to arrive", not a lab performance score. It's consistent from run to run, which is what matters for spotting trends.</li>
<li><strong>The content check catches soft failures.</strong> A WordPress site with a broken database often returns a page with an error message. With <code>expect</code>, that counts as down.</li>
<li><strong>Failing the job on downtime</strong> means GitHub's normal failed-workflow email doubles as your alert, with no extra service.</li>
<li><strong>An honest user agent.</strong> Some hosts block unknown bots. If a client's firewall blocks your checks, allowlist the user agent rather than disguising it as a browser.</li>
</ul>
<h2>Scheduling it</h2>
<pre><code class="language-yaml"># .github/workflows/check.yml
name: Site checks
on:
  schedule:
    - cron: "*/30 * * * *"   # every 30 minutes (UTC)
  workflow_dispatch: {}

permissions:
  contents: write

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Run checks
        id: run
        run: node check.mjs
        continue-on-error: true
      - name: Commit results
        run: |
          git config user.name "site-checks"
          git config user.email "site-checks@users.noreply.github.com"
          git add data
          git commit -m "checks $(date -u +%FT%TZ)" || echo "nothing to commit"
          git pull --rebase --autostash
          git push
      - name: Fail if a site was down
        if: steps.run.outcome == 'failure'
        run: exit 1
</code></pre>
<p>Things to know about scheduled workflows:</p>
<ul>
<li><strong>Cron is in UTC.</strong> Every 30 minutes doesn't care, but if you add a daily job, convert from Pakistan time (UTC+5).</li>
<li><strong>Schedules aren't exact.</strong> Runs can start late when GitHub is busy. For maintenance reporting that's fine; for alerting within a minute, use a dedicated monitoring service.</li>
<li><strong>Minutes count.</strong> Private repositories get a monthly allowance of free Actions minutes depending on your plan. Each run here takes well under a minute of compute, but check your plan's quota and lower the frequency if needed.</li>
<li><strong>Inactive repositories.</strong> GitHub can disable scheduled workflows in a repository with no activity for a long period. Because this workflow commits results, the repository stays active.</li>
</ul>
<h2>The monthly report</h2>
<pre><code class="language-js">// report.mjs  usage: node report.mjs 2026-10
import { readdir, readFile, writeFile } from "node:fs/promises";

const month = process.argv[2] ?? new Date().toISOString().slice(0, 7);
const files = (await readdir("data")).filter((f) =&gt; f.startsWith(month));
const rows = (await Promise.all(files.map(async (f) =&gt; JSON.parse(await readFile(`data/${f}`, "utf8"))))).flat();

const byUrl = Map.groupBy(rows, (r) =&gt; r.url);
let md = `# Website maintenance report: ${month}\n\n`;
md += `| Client | Page | Checks | Up | Uptime | Median response | SSL days left |\n|---|---|---|---|---|---|---|\n`;

for (const [url, list] of byUrl) {
  const up = list.filter((r) =&gt; r.up).length;
  const times = list.map((r) =&gt; r.ms).filter((n) =&gt; n != null).sort((a, b) =&gt; a - b);
  const median = times.length ? times[Math.floor(times.length / 2)] : "-";
  const latest = list.at(-1);
  md += `| ${latest.client} | ${url} | ${list.length} | ${up} | ${((up / list.length) * 100).toFixed(2)}% | ${median} ms | ${latest.certDays ?? "?"} |\n`;
}

const incidents = rows.filter((r) =&gt; !r.up);
md += `\n## Incidents\n\n`;
md += incidents.length
  ? incidents.map((r) =&gt; `- ${r.time}: ${r.url} (${r.error ?? `status ${r.status}`}${r.contentOk === false ? ", expected text missing" : ""})`).join("\n")
  : "No failed checks this month.";

await writeFile(`report-${month}.md`, md + "\n");
console.log(md);
</code></pre>
<p><code>Map.groupBy</code> needs Node 21 or later. On Node 20, replace it with a small <code>reduce</code>, or set <code>node-version: 22</code> in the workflow.</p>
<p>Run it locally after pulling, or add a monthly workflow (<code>cron: "0 4 1 * *"</code> is 9 am Pakistan time on the 1st) that runs the script and uploads the Markdown as an artifact.</p>
<h2>Turning the report into a retainer</h2>
<p>The tooling is the easy part. A few notes on the client side:</p>
<ul>
<li><strong>Offer it at handover, not months later.</strong> The moment you deliver is when the client is most aware of how much work went into the site. Something like: "I'm happy to keep an eye on the site after launch: uptime and SSL checks every 30 minutes, a short monthly report, and up to an hour of small fixes. Here's what that costs per month."</li>
<li><strong>Define what's included.</strong> Monitoring, the report, renewals, small content edits up to a time limit. New features are quoted separately. Without a limit, a retainer quietly turns into unlimited free work.</li>
<li><strong>Send the report even when nothing happened.</strong> "All checks passed, certificate renews automatically on the 12th" is reassuring, and it reminds the client you exist.</li>
<li><strong>Act before they notice.</strong> When a check fails, message the client with what happened and what you did. That one message is worth more than the whole report.</li>
</ul>
<p>If the project came through Upwork, the follow-up after delivery is the natural moment to suggest this. The Digital Dialogue guide on <a href="https://www.digitaldialogue.pk/blogs/freelancing/customer-follow-up-get-the-next-job-from-the-same-upwork-client">getting the next job from the same Upwork client</a> covers how to time and word that follow-up; a concrete monitoring offer gives it something useful to say.</p>
<h2>Recap</h2>
<ul>
<li>A private repo, a JSON list of sites and a scheduled workflow give you free monitoring.</li>
<li>Check status, response time, certificate expiry and a keyword per page.</li>
<li>Fail the job on downtime so GitHub emails you.</li>
<li>Generate a monthly Markdown report with uptime, median response and incidents.</li>
<li>Offer the retainer at handover, define its limits, and send the report every month.</li>
</ul>
<hr />
<p><em>Abid Ali is a web developer and freelancer in Pakistan. He writes beginner-friendly freelancing guides at <a href="https://www.digitaldialogue.pk/">Digital Dialogue</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[When Ten Sound Buttons Play at Once: Voice Limits, Choke Groups and a Master Compressor with the Web Audio API]]></title><description><![CDATA[A soundboard works perfectly in testing, because you press one button at a time and listen politely. Then real people get hold of it. They mash five buttons in a second, hold one down, tap the same ai]]></description><link>https://abidali671.hashnode.dev/when-ten-sound-buttons-play-at-once-voice-limits-choke-groups-and-a-master-compressor-with-the-web-audio-api</link><guid isPermaLink="true">https://abidali671.hashnode.dev/when-ten-sound-buttons-play-at-once-voice-limits-choke-groups-and-a-master-compressor-with-the-web-audio-api</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webaudio]]></category><category><![CDATA[webdev]]></category><category><![CDATA[frontend]]></category><category><![CDATA[audio]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Sat, 17 Oct 2026 17:04:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/d1e0aa6e-bae2-42ec-ae86-2119b92f1663.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A soundboard works perfectly in testing, because you press one button at a time and listen politely. Then real people get hold of it. They mash five buttons in a second, hold one down, tap the same airhorn twelve times in a row. The result is a distorted wall of noise, a laptop fan spinning up, and on some phones, audio that cuts out completely.</p>
<p>Most soundboards handle playback like this:</p>
<pre><code class="language-js">button.onclick = () =&gt; new Audio(sound.url).play();
</code></pre>
<p>Every tap creates a new player, nothing ever stops, and every clip adds its full volume on top of the others. This post replaces that with a small audio engine built on the Web Audio API that:</p>
<ul>
<li>decodes each clip once and reuses it,</li>
<li>limits how many sounds can play at the same time,</li>
<li>lets a sound "choke" (cut off) its own previous instance or a group,</li>
<li>fades sounds out instead of cutting them with a click,</li>
<li>and runs everything through a master gain and compressor so stacked sounds don't clip.</li>
</ul>
<p>It's about 120 lines of plain JavaScript.</p>
<h2>Why stacked sounds distort</h2>
<p>Digital audio samples live between -1 and 1. When two sounds play at once, the browser adds their samples together. Two loud clips peaking near 1 add up to nearly 2, and anything beyond the limit is flattened ("clipped") at the output. Clipping sounds harsh and crackly, and with meme sounds, which are often already mastered loud, it takes only two or three overlapping clips.</p>
<p>You can't fix that with per-sound volume alone, because you don't know in advance how many will overlap. You need something on the combined signal: a master gain to leave headroom, and a compressor to tame peaks when many sounds stack up.</p>
<h2>The audio graph</h2>
<pre><code class="language-text">[source]─[gain]─┐
[source]─[gain]─┼─[master gain]─[compressor]─[destination]
[source]─[gain]─┘
</code></pre>
<p>Each playing sound gets its own <code>AudioBufferSourceNode</code> (the thing that plays a decoded clip) and its own <code>GainNode</code> (so it can be faded out independently). All of them feed a shared master chain.</p>
<pre><code class="language-js">class SoundEngine {
  constructor({ maxVoices = 6, masterVolume = 0.8 } = {}) {
    this.ctx = new AudioContext();
    this.maxVoices = maxVoices;
    this.buffers = new Map();     // url -&gt; Promise&lt;AudioBuffer&gt;
    this.voices = [];             // currently playing voices, oldest first

    this.master = this.ctx.createGain();
    this.master.gain.value = masterVolume;

    this.compressor = this.ctx.createDynamicsCompressor();
    this.compressor.threshold.value = -12; // dB: start compressing here
    this.compressor.knee.value = 6;
    this.compressor.ratio.value = 8;
    this.compressor.attack.value = 0.003;  // seconds
    this.compressor.release.value = 0.25;

    this.master.connect(this.compressor).connect(this.ctx.destination);
  }
}
</code></pre>
<p><code>connect()</code> returns the node you connected to, which is why the chain reads left to right.</p>
<p>The compressor settings are a starting point. A threshold around -12 dB with a high ratio behaves almost like a limiter: quiet single sounds pass through untouched, and only big stacks get squeezed. If single clips sound squashed, raise the threshold.</p>
<h2>Decode once, play many times</h2>
<p>Fetching and decoding a clip on every tap wastes bandwidth and adds latency. Cache the promise, not the result, so two quick taps on a clip that's still loading share one request:</p>
<pre><code class="language-js">SoundEngine.prototype.load = function (url) {
  if (!this.buffers.has(url)) {
    const p = fetch(url)
      .then((r) =&gt; {
        if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`);
        return r.arrayBuffer();
      })
      .then((data) =&gt; this.ctx.decodeAudioData(data));
    p.catch(() =&gt; this.buffers.delete(url)); // allow a retry after failure
    this.buffers.set(url, p);
  }
  return this.buffers.get(url);
};
</code></pre>
<p>A decoded <code>AudioBuffer</code> can be played by any number of source nodes at once; the buffer itself is never consumed. Source nodes, on the other hand, are one-shot: once a source has been started, it can't be started again. Creating a new one per play is cheap and is how the API is meant to be used.</p>
<p>Keep in mind that decoded audio is uncompressed and takes far more memory than the MP3. For a board with hundreds of clips, decode on demand and consider evicting buffers that haven't been played in a while.</p>
<h2>Playing a voice</h2>
<pre><code class="language-js">SoundEngine.prototype.play = async function (url, { volume = 1, choke = null } = {}) {
  if (this.ctx.state === "suspended") await this.ctx.resume();
  const buffer = await this.load(url);

  // 1. Choke: stop anything in the same group (by default, the same clip).
  const group = choke ?? url;
  for (const v of this.voices) if (v.group === group) this.release(v);

  // 2. Voice limit: if we're full, release the oldest.
  while (this.voices.filter((v) =&gt; !v.releasing).length &gt;= this.maxVoices) {
    this.release(this.voices.find((v) =&gt; !v.releasing));
  }

  // 3. Build and start the voice.
  const source = this.ctx.createBufferSource();
  source.buffer = buffer;
  const gain = this.ctx.createGain();
  gain.gain.value = volume;
  source.connect(gain).connect(this.master);

  const voice = { source, gain, group, releasing: false };
  source.onended = () =&gt; {
    gain.disconnect();
    this.voices = this.voices.filter((v) =&gt; v !== voice);
  };

  this.voices.push(voice);
  source.start();
  return voice;
};
</code></pre>
<p>Three behaviours are packed in here.</p>
<p><strong><code>resume()</code> on the first play.</strong> Browsers create an <code>AudioContext</code> in a suspended state until there's a user gesture. Calling <code>resume()</code> inside the click handler unlocks it. Do it before awaiting the network if you can, since some browsers want the resume tied closely to the gesture: calling <code>engine.ctx.resume()</code> synchronously at the top of your click handler is the safest pattern.</p>
<p><strong>Choke groups.</strong> By default, tapping the same button again stops the previous instance of that clip before starting a new one. That's what most people expect from a sound button: tapping the airhorn ten times gives you ten restarts, not ten stacked airhorns. Pass a shared <code>choke</code> name to make related sounds cut each other off. For example, put every "music sting" in one group so only one plays at a time, while short effects stack freely.</p>
<p><strong>A voice limit.</strong> When too many sounds are playing, the oldest gets released. Six is a reasonable default for a soundboard; nobody can tell a seventh overlapping clip apart anyway, and mobile devices thank you for not running dozens of nodes.</p>
<h2>Release with a fade, not a cut</h2>
<p>Calling <code>source.stop()</code> immediately cuts the waveform wherever it is, which causes an audible click. A fade of a few tens of milliseconds sounds clean and still feels instant:</p>
<pre><code class="language-js">SoundEngine.prototype.release = function (voice, fade = 0.04) {
  if (!voice || voice.releasing) return;
  voice.releasing = true;
  const t = this.ctx.currentTime;
  const g = voice.gain.gain;
  g.cancelScheduledValues(t);
  g.setValueAtTime(g.value, t);           // anchor the ramp at the current value
  g.linearRampToValueAtTime(0, t + fade);
  voice.source.stop(t + fade);
};

SoundEngine.prototype.stopAll = function () {
  for (const v of [...this.voices]) this.release(v);
};
</code></pre>
<p>The <code>setValueAtTime(g.value, t)</code> line matters. Ramps in the Web Audio API start from the previous scheduled event. Without an anchor at the current time, the ramp might start from an earlier point and jump.</p>
<p>The <code>onended</code> handler from <code>play()</code> cleans up after the scheduled stop, so released voices disappear from the list on their own.</p>
<h2>Wiring it to the UI</h2>
<pre><code class="language-js">const engine = new SoundEngine({ maxVoices: 6 });

document.querySelector(".board").addEventListener("click", (e) =&gt; {
  const btn = e.target.closest("button[data-src]");
  if (!btn) return;
  engine.ctx.resume(); // synchronous call inside the gesture
  engine.play(btn.dataset.src, {
    volume: Number(btn.dataset.volume ?? 1),
    choke: btn.dataset.choke || null,
  });
});

document.querySelector("#stop-all").addEventListener("click", () =&gt; engine.stopAll());

// Optional: warm the cache for visible buttons so the first tap is instant.
for (const btn of document.querySelectorAll("button[data-src]")) {
  engine.load(btn.dataset.src).catch(() =&gt; {});
}
</code></pre>
<p>Preloading every button on a big board isn't a good idea on mobile data. Warm the cache only for the buttons on screen, or for the ones the user is likely to tap next.</p>
<p>A big <strong>Stop all</strong> button is the single most appreciated feature on any soundboard used in a group. Make it easy to find.</p>
<h2>Volume per clip</h2>
<p>Clips from different sources come in at very different levels. Storing a per-clip <code>volume</code> (a number from 0 to 1 set once when you add the clip) and passing it to <code>play()</code> is a cheap way to keep a loud airhorn from drowning out a quiet voice line. The real fix is normalising clips when you add them to the library, but a per-clip trim is useful for the stragglers.</p>
<h2>Testing what you built</h2>
<ul>
<li><strong>Mash test.</strong> Tap five different buttons as fast as you can. You should hear all of them, without crackling.</li>
<li><strong>Repeat test.</strong> Tap one button ten times quickly. You should hear restarts, not a growing pile.</li>
<li><strong>Mobile test.</strong> Try it on a mid-range Android phone. If audio stutters, lower <code>maxVoices</code>.</li>
<li><strong>Background test.</strong> Switch tabs mid-sound and come back. Some browsers suspend the context in the background; check <code>ctx.state</code> and resume on the next tap.</li>
</ul>
<h2>See it in context</h2>
<p>Boards full of short, punchy effects are where this matters most. Try mashing a few buttons on <a href="https://www.memetap.pk/soundboards/sound-effects">Meme Tap's sound effects soundboard</a> and you'll hear why stacking needs managing. The code in this post is a general pattern I'd use for any soundboard, not the site's actual source.</p>
<h2>Recap</h2>
<ul>
<li>Decode each clip once; create a new <code>AudioBufferSourceNode</code> per play.</li>
<li>Give each voice its own <code>GainNode</code> so it can fade independently.</li>
<li>Choke the previous instance of the same sound, and optionally whole groups.</li>
<li>Cap the number of simultaneous voices and release the oldest.</li>
<li>Fade out over a few tens of milliseconds instead of hard stops.</li>
<li>Leave headroom with a master gain, and catch big stacks with a <code>DynamicsCompressorNode</code>.</li>
</ul>
<hr />
<p><em>Abid Ali is a full-stack web developer in Pakistan. He builds <a href="https://www.memetap.pk/">Meme Tap</a>, a free browser soundboard with one-tap meme sound buttons.</em></p>
]]></content:encoded></item><item><title><![CDATA[Link Previews for Product Pages: Images, Prices, Variants and the Cache That Won't Let Go]]></title><description><![CDATA[Product links get shared more casually than almost anything else on the web. Someone pastes a kurta into a WhatsApp group to ask "this one or the blue?", a reseller drops a link into a Facebook group,]]></description><link>https://abidali671.hashnode.dev/link-previews-for-product-pages-images-prices-variants-and-the-cache-that-won-t-let-go</link><guid isPermaLink="true">https://abidali671.hashnode.dev/link-previews-for-product-pages-images-prices-variants-and-the-cache-that-won-t-let-go</guid><category><![CDATA[ecommerce]]></category><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[HTML]]></category><category><![CDATA[Next.js]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Sat, 17 Oct 2026 17:03:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/00b180ce-0a10-4231-a52a-2a2ab15d5a72.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Product links get shared more casually than almost anything else on the web. Someone pastes a kurta into a WhatsApp group to ask "this one or the blue?", a reseller drops a link into a Facebook group, a customer sends a phone case to themselves as a reminder. Each of those shares is a tiny ad you didn't pay for, and the link preview is the whole ad.</p>
<p>Product pages also break previews in ways blog posts don't. Prices change, stock runs out, one URL can mean six colours, and images live on a CDN with odd URLs. This post walks through the decisions that matter for product link previews, with markup you can copy.</p>
<h2>Start with the basics every product page needs</h2>
<p>The minimum set that every major chat app and social platform understands:</p>
<pre><code class="language-html">&lt;meta property="og:type" content="product" /&gt;
&lt;meta property="og:title" content="Embroidered Lawn Kurta, Sea Green" /&gt;
&lt;meta property="og:description" content="Unstitched 3-piece lawn suit with embroidered neckline. Machine washable." /&gt;
&lt;meta property="og:url" content="https://shop.example.com/products/embroidered-lawn-kurta-sea-green" /&gt;
&lt;meta property="og:image" content="https://shop.example.com/og/products/elk-sea-green.jpg" /&gt;
&lt;meta property="og:image:width" content="1200" /&gt;
&lt;meta property="og:image:height" content="630" /&gt;
&lt;meta property="og:image:alt" content="Sea green embroidered lawn kurta on a plain background" /&gt;
&lt;meta property="og:site_name" content="Example Shop" /&gt;
&lt;meta name="twitter:card" content="summary_large_image" /&gt;
</code></pre>
<p>A note on <code>og:type</code>: chat apps mostly treat any type the same way and just render title, description and image. Setting <code>product</code> is still worthwhile because some platforms that care about commerce (Pinterest's product pins and Facebook's commerce features, for example) read product-specific tags, and they look for <code>product</code> as the type.</p>
<h2>The image: product photos are usually the wrong shape</h2>
<p>Product photography is typically square or portrait, on white. Link preview cards in most apps are wide, roughly 1.91:1. Hand a crawler a square photo and one of two things happens: it gets cropped to a strip through the middle (goodbye neckline and hem) or the app falls back to a small thumbnail layout.</p>
<p>The fix is to generate a dedicated share image per product, not to reuse the catalogue photo:</p>
<ul>
<li>Place the square product photo on a 1200×630 canvas with your brand background, product on one side.</li>
<li>Leave the other side for the product name, and only the product name. More on price below.</li>
<li>Keep the product inside the central area, because each app crops a little differently.</li>
<li>Export as JPEG or PNG with a stable URL.</li>
</ul>
<p>If you're on Next.js, you can generate this per product with <code>ImageResponse</code> from <code>next/og</code> in an <code>opengraph-image.tsx</code> route, reading the product's photo and name. Just make sure the generated image URL is absolute in the final HTML.</p>
<p><strong>Stable image URLs matter more than you'd think.</strong> Many e-commerce platforms serve images through URLs with signatures or expiry timestamps. That's fine for your page, but platforms cache previews, sometimes for a long time. If the cached card points at a URL that expires next week, the preview silently turns into a grey box. Use a permanent URL for <code>og:image</code>, and change the filename (or add a version query string) when the image actually changes.</p>
<h2>Price: tempting, and risky</h2>
<p>The obvious idea is to put the price in the title or on the image: "Embroidered Lawn Kurta, Rs 4,500". It does make the card more useful. But think about what happens next.</p>
<p>Previews are cached per URL, often for days or weeks, and you don't control when they refresh. When the sale ends, or the price goes up, people keep sharing a card with the old price. A customer taps through, sees a different number, and feels misled, even though you did nothing wrong.</p>
<p>My rule:</p>
<ul>
<li><strong>Don't bake the price into <code>og:title</code>, <code>og:description</code> or the image</strong> unless the price really never changes.</li>
<li><strong>Do use structured product price tags</strong> where platforms that support them can read the current value:</li>
</ul>
<pre><code class="language-html">&lt;meta property="product:price:amount" content="4500.00" /&gt;
&lt;meta property="product:price:currency" content="PKR" /&gt;
&lt;meta property="product:availability" content="in stock" /&gt;
&lt;meta property="product:condition" content="new" /&gt;
&lt;meta property="product:retailer_item_id" content="ELK-SEA-GREEN" /&gt;
</code></pre>
<p>Ordinary chat previews ignore these, so they don't put a stale price into anyone's group chat, and the platforms that do read them treat them as structured data rather than as text baked into a cached card. For search engines, the equivalent is schema.org <code>Product</code> JSON-LD with an <code>Offer</code>. Keep all three (visible price, product tags, JSON-LD) generated from the same field so they can't disagree.</p>
<h2>Variants: one URL per thing people actually share</h2>
<p>A product with six colours is where most shops get previews wrong. Typical setups:</p>
<ol>
<li><strong>One URL, colour picked with JavaScript.</strong> Everyone shares the same URL, and the preview always shows the default colour. Someone asking "this one or the blue?" sends two links that render identical cards.</li>
<li><strong>One URL, colour in a query parameter</strong> (<code>?variant=123</code>). Better, but only if your server reads that parameter and renders matching tags. If the tags are the same for every variant, you're back to case 1.</li>
<li><strong>A distinct URL per variant</strong> (<code>/products/kurta-sea-green</code>, <code>/products/kurta-blue</code>). Each variant has its own tags and image.</li>
</ol>
<p>Option 3 gives the best previews, but you should decide based on how people share. For clothes, colour is the decision, so per-variant previews are worth it. For a phone case in fifteen colours that looks the same in a thumbnail, one preview is fine.</p>
<p>If you go with query parameters, render the tags on the server from the parameter. In Next.js App Router:</p>
<pre><code class="language-ts">// app/products/[slug]/page.tsx
import type { Metadata } from "next";
import { getProduct } from "@/lib/catalog";

type Props = {
  params: Promise&lt;{ slug: string }&gt;;
  searchParams: Promise&lt;{ variant?: string }&gt;;
};

export async function generateMetadata({ params, searchParams }: Props): Promise&lt;Metadata&gt; {
  const { slug } = await params;
  const { variant: variantId } = await searchParams;
  const product = await getProduct(slug);
  const variant = product.variants.find((v) =&gt; v.id === variantId) ?? product.defaultVariant;

  const url = variant === product.defaultVariant
    ? `/products/${slug}`
    : `/products/${slug}?variant=${variant.id}`;

  return {
    title: `${product.name}, ${variant.label}`,
    description: product.shortDescription,
    alternates: { canonical: `/products/${slug}` },
    openGraph: {
      type: "website", // Next's typed options don't include "product"; see the note below
      url,
      title: `${product.name}, ${variant.label}`,
      description: product.shortDescription,
      images: [{ url: variant.shareImage, width: 1200, height: 630, alt: `${product.name} in ${variant.label}` }],
    },
    other: {
      "product:price:amount": variant.price.toFixed(2),
      "product:price:currency": "PKR",
      "product:availability": variant.inStock ? "in stock" : "out of stock",
    },
  };
}
</code></pre>
<p>Two things to notice. The canonical stays on the base product (so search engines see one page), while <code>og:url</code> points at the variant (so the share card matches what was shared). And Next's <code>openGraph.type</code> doesn't accept <code>"product"</code> in its typed options; if you need <code>og:type</code> to be <code>product</code>, render that one tag yourself and verify the final HTML.</p>
<p>The <code>other</code> field renders as <code>&lt;meta name="..."&gt;</code>, not <code>property</code>. Many parsers accept either for <code>product:*</code> tags, but if a specific platform needs <code>property</code>, render those tags yourself. Always check the real HTML instead of trusting the framework.</p>
<h2>Out of stock and discontinued products</h2>
<p>Products disappear, but links to them live on in chats for months. Decide what the preview should do:</p>
<ul>
<li><strong>Out of stock, coming back:</strong> keep the page and the card. Don't swap the image for an "out of stock" banner; the card might get cached that way and stay "sold out" after restock. Show stock status on the page itself, where it's always live.</li>
<li><strong>Discontinued:</strong> keep the URL alive with a page that says so and links to similar products. Its tags should describe that page honestly. A 404 means future shares of the old link render as a bare URL, or a generic error card.</li>
<li><strong>Never redirect a dead product to the homepage silently.</strong> The preview becomes your homepage card with no explanation, which looks like a broken or suspicious link.</li>
</ul>
<h2>Testing: share the way customers do</h2>
<p>Product previews fail in ways you only see from outside:</p>
<ol>
<li>Fetch the page as a crawler would, without cookies or JavaScript, and look at the tags. A product page that renders tags client-side shows nothing to most crawlers.</li>
<li>Check the image URL directly: it should return 200, an image content type, and no redirect to a login or a geo-block.</li>
<li>Paste the URL into a chat with yourself before announcing a launch.</li>
<li>For Facebook groups and Marketplace-style sharing, preview how the card will look, and refresh Facebook's cache after you change the image. I use the <a href="https://www.ogscope.pk/facebook-link-preview">Facebook link preview checker on OG Scope</a> to see what Facebook's card will show and catch missing or mis-sized images before the link goes out.</li>
</ol>
<h2>Checklist</h2>
<ul>
<li> Dedicated 1200×630 share image per product (or per variant), with a stable URL.</li>
<li> No price in title, description or image unless it never changes.</li>
<li> <code>product:price:*</code> tags and schema.org <code>Offer</code> generated from the same price field.</li>
<li> Per-variant <code>og:url</code> and image if variants are what people compare.</li>
<li> Out-of-stock pages keep their normal card; discontinued pages stay alive.</li>
<li> Tags rendered on the server, verified from the outside.</li>
</ul>
<p>A product link is usually shared at the exact moment someone is deciding whether to buy. It's worth a few hours to make sure the card in that chat shows the right thing.</p>
<hr />
<p><em>Abid Ali is a web developer in Pakistan. He builds <a href="https://www.ogscope.pk/">OG Scope</a>, a free tool for checking Open Graph tags and link previews.</em></p>
]]></content:encoded></item><item><title><![CDATA[Client Review Without the Chaos: Staging Links, a Feedback Template and Approval Gates for Freelance Web Projects]]></title><description><![CDATA[The build is rarely the hard part of a freelance web project. The review is. You send a link, and feedback arrives over the next four days as a WhatsApp voice note, two screenshots with red circles, a]]></description><link>https://abidali671.hashnode.dev/client-review-without-the-chaos-staging-links-a-feedback-template-and-approval-gates-for-freelance-web-projects</link><guid isPermaLink="true">https://abidali671.hashnode.dev/client-review-without-the-chaos-staging-links-a-feedback-template-and-approval-gates-for-freelance-web-projects</guid><category><![CDATA[Freelancing]]></category><category><![CDATA[webdev]]></category><category><![CDATA[GitHub]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Sat, 17 Oct 2026 09:51:01 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/bcf4f2d0-34bf-4ad1-bf6d-dec9964412a5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The build is rarely the hard part of a freelance web project. The review is. You send a link, and feedback arrives over the next four days as a WhatsApp voice note, two screenshots with red circles, an email that says "a few small things", and a comment on the old link you sent last week. Some of it is a bug, some of it is a new feature, and some of it contradicts what was approved on the call.</p>
<p>Developers have good tools for reviewing each other's code. We rarely give clients a review process with the same structure. This post is the one I'd set up for any small client site: a stable staging link per milestone, one place for feedback with a template, a simple triage, and an approval gate before you mark the milestone as done.</p>
<h2>1. One stable staging URL per milestone</h2>
<p>Clients get confused by moving targets. If every push gives them a new preview URL, they'll review an old one and report bugs you fixed yesterday.</p>
<p>Give them <strong>one stable address</strong> for the milestone under review, for example <code>staging.clientsite.com</code>, or a fixed preview alias your host lets you assign to a branch. Your own throwaway previews can stay per-commit. The client only ever sees the stable one.</p>
<p>A simple branch model:</p>
<pre><code class="language-text">main         -&gt; production (after final approval)
staging      -&gt; staging.clientsite.com (what the client reviews)
feature/*    -&gt; your own previews
</code></pre>
<p>Merge to <code>staging</code> when a batch is ready for review, not on every commit. Each merge to <code>staging</code> is a "review round".</p>
<p>Put a small banner on staging so nobody confuses it with the real site, and keep it out of search:</p>
<pre><code class="language-tsx">// components/StagingBanner.tsx
export function StagingBanner() {
  if (process.env.NEXT_PUBLIC_ENV !== "staging") return null;
  return (
    &lt;div style={{ background: "#fde68a", padding: 8, textAlign: "center", fontSize: 14 }}&gt;
      Review copy: round {process.env.NEXT_PUBLIC_REVIEW_ROUND}. Changes here are not live.
    &lt;/div&gt;
  );
}
</code></pre>
<pre><code class="language-html">&lt;meta name="robots" content="noindex, nofollow" /&gt;
</code></pre>
<p>If the content is sensitive, add basic auth or your host's password protection. Send the password once, in the same message as the link.</p>
<h2>2. One place for feedback, with a template</h2>
<p>The single biggest improvement is a rule: <strong>feedback counts when it's in the feedback place.</strong> Voice notes are fine for a conversation, but someone has to write the item down, and that someone should be the client, using a template that forces the useful details.</p>
<p>If the client is comfortable with GitHub, an issue form works well. Create <code>.github/ISSUE_TEMPLATE/feedback.yml</code>:</p>
<pre><code class="language-yaml">name: Site feedback
description: Something to change or fix on the staging site
labels: ["feedback"]
body:
  - type: input
    id: page
    attributes:
      label: Page URL
      description: Copy it from the address bar on the staging site
    validations:
      required: true
  - type: dropdown
    id: device
    attributes:
      label: Device
      options: ["Phone", "Tablet", "Laptop / desktop"]
    validations:
      required: true
  - type: textarea
    id: what
    attributes:
      label: What do you see now?
    validations:
      required: true
  - type: textarea
    id: want
    attributes:
      label: What should it be instead?
    validations:
      required: true
  - type: textarea
    id: screenshot
    attributes:
      label: Screenshot (optional)
      description: Paste or drag an image here
</code></pre>
<p>Most clients aren't on GitHub, and that's fine. The same five fields work as a shared spreadsheet, a simple form, or a pinned message format in chat:</p>
<pre><code class="language-text">Page:
Device:
What I see:
What I want instead:
Screenshot:
</code></pre>
<p>What matters is that every item says <em>where</em>, <em>on what device</em>, <em>what's wrong</em> and <em>what right looks like</em>. "Make the header better" becomes "Home page, phone: the menu button overlaps the logo; the logo should be fully visible."</p>
<h2>3. Triage every item into one of three labels</h2>
<p>Before touching code, label each item. Do it within a day so the client knows where they stand:</p>
<table>
<thead>
<tr>
<th>Label</th>
<th>Meaning</th>
<th>What happens</th>
</tr>
</thead>
<tbody><tr>
<td><code>bug</code></td>
<td>Doesn't match what was agreed, or is broken</td>
<td>Fixed in this round, no charge</td>
</tr>
<tr>
<td><code>in-scope</code></td>
<td>A change that fits the agreed scope and revision rounds</td>
<td>Done in this round</td>
</tr>
<tr>
<td><code>new-request</code></td>
<td>Outside the agreed scope</td>
<td>Quoted separately, or parked for later</td>
</tr>
</tbody></table>
<p>Then reply with a short summary, not a debate:</p>
<pre><code class="language-text">Round 2 feedback: 9 items
- 3 bugs: fixing today
- 4 in scope: done by Thursday
- 2 new requests (blog search, Urdu version): I'll send a separate quote,
  they won't block this milestone
</code></pre>
<p>The <code>new-request</code> label is where most scope arguments get defused. You're not saying no. You're saying "yes, separately", in writing, before it's done for free.</p>
<h2>4. Make review rounds countable</h2>
<p>If your agreement includes two revision rounds, make the rounds visible. Tag the commit you deploy to staging for each round:</p>
<pre><code class="language-bash">git checkout staging
git merge --no-ff feature/round-2-fixes
git tag -a review-m1-r2 -m "Milestone 1, review round 2"
git push origin staging --tags
</code></pre>
<p>Now you can generate a plain-language changelog for the client between rounds:</p>
<pre><code class="language-bash">git log review-m1-r1..review-m1-r2 --pretty=format:"- %s" --no-merges
</code></pre>
<p>That only works if your commit messages are readable by a non-developer, which is a good habit anyway: "Fix menu overlapping logo on phones" rather than "fix nav z-index".</p>
<h2>5. An approval gate before the milestone is submitted</h2>
<p>Here's the step that protects both sides. Before you mark a milestone as complete on the platform or send the final invoice, send an approval checklist that maps to the agreed deliverables:</p>
<pre><code class="language-markdown">## Milestone 1 approval: Home, About, Services, Contact

- [ ] All four pages are on staging and match the approved design
- [ ] Contact form sends to info@clientsite.com (test message received)
- [ ] Checked on a phone and a laptop
- [ ] Round 1 and 2 feedback items are closed or labelled new-request
- [ ] Copy and images are final, or listed below as pending from you

Pending from client: team photos for the About page
</code></pre>
<p>Ask for a short written "approved" on that checklist. Then deploy <code>staging</code> to <code>main</code>, tag it, and submit the milestone.</p>
<p>If you work through Upwork, this fits how the platform already works. On a fixed-price contract the client funds a milestone, you submit the work against it, and the client approves it (or the review window ends) before payment can move. Digital Dialogue's guide on <a href="https://www.digitaldialogue.pk/blogs/freelancing/how-upwork-works-for-a-freelance-web-developer">how Upwork works for a freelance web developer</a> walks through that loop, including why you shouldn't start building on an unfunded milestone. A written approval on staging before you submit means the platform approval is a formality, not a surprise.</p>
<h2>6. Keep a review record</h2>
<p>At the end of each round, save:</p>
<ul>
<li>the staging tag (<code>review-m1-r2</code>),</li>
<li>the triage summary you sent,</li>
<li>the client's approval message.</li>
</ul>
<p>It takes two minutes. If there's ever a disagreement about what was agreed or approved, you have dates and specifics instead of memory. It's also a ready-made source for your next case study.</p>
<h2>What this looks like from the client's side</h2>
<p>From the client's point of view, the process is simple:</p>
<ol>
<li>"Here's the link for this round, and the five things to include when you send feedback."</li>
<li>"Here's what I'm fixing, what's in scope, and what's a separate request."</li>
<li>"Here's what changed since last round."</li>
<li>"Here's the checklist. Reply 'approved' when you're happy."</li>
</ol>
<p>Clients don't experience this as bureaucracy. They experience it as a developer who has done this before. And you stop losing evenings to the fourth version of "just one more small thing".</p>
<hr />
<p><em>Abid Ali is a web developer who freelances from Pakistan and writes practical freelancing guides at <a href="https://www.digitaldialogue.pk/">Digital Dialogue</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Offline Sound Buttons with a Service Worker: Caching Audio, Handling Range Requests and Staying Inside Storage Limits]]></title><description><![CDATA[A soundboard is a great candidate for offline support. The clips are small, they don't change once published, and people use them in places with bad signal: a bus, a basement, the back of a classroom.]]></description><link>https://abidali671.hashnode.dev/offline-sound-buttons-with-a-service-worker-caching-audio-handling-range-requests-and-staying-inside-storage-limits</link><guid isPermaLink="true">https://abidali671.hashnode.dev/offline-sound-buttons-with-a-service-worker-caching-audio-handling-range-requests-and-staying-inside-storage-limits</guid><category><![CDATA[service worker]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[PWA]]></category><category><![CDATA[webdev]]></category><category><![CDATA[frontend]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Sat, 17 Oct 2026 09:50:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/40a5d771-8c40-48e7-88ea-667928bf1cf9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A soundboard is a great candidate for offline support. The clips are small, they don't change once published, and people use them in places with bad signal: a bus, a basement, the back of a classroom. Tap a favourite and it should just play.</p>
<p>It's also a surprisingly easy place to get service worker caching wrong. Audio has quirks that images and JSON don't: browsers request media in byte ranges, cross-origin audio without CORS fills your storage quota much faster than you'd expect, and a big library can't be cached all at once.</p>
<p>This post walks through a caching setup for short audio clips that handles those cases. It's plain service worker code, no library, so you can see what's going on. If you use Workbox, the same ideas map onto its strategies and its range-requests plugin.</p>
<h2>Decide what to cache before you write code</h2>
<p>A board with thousands of clips (Meme Tap's <a href="https://www.memetap.pk/soundboards/memes">memes soundboard</a> alone has thousands of buttons) can't be precached. You'd download a huge amount of audio the user will never play. So choose a policy:</p>
<ul>
<li><strong>App shell:</strong> precache HTML, CSS, JS and icons on install. Small, required.</li>
<li><strong>Clips the user plays:</strong> cache on first play ("runtime caching"), with a size cap.</li>
<li><strong>Favourites:</strong> cache explicitly when the user favourites a clip, and keep them out of normal eviction.</li>
</ul>
<p>That gives you two audio caches with different rules: a general one that can be trimmed, and a favourites one that only shrinks when the user unfavourites.</p>
<pre><code class="language-js">// sw.js
const SHELL = "shell-v3";
const CLIPS = "clips-v1";      // runtime, capped
const FAVS = "favs-v1";        // explicit, not auto-evicted
const MAX_CLIPS = 150;
</code></pre>
<h2>Gotcha 1: Media elements ask for byte ranges</h2>
<p>When an <code>&lt;audio&gt;</code> element loads a file, many browsers send a <code>Range: bytes=0-</code> header and expect a <code>206 Partial Content</code> response. Safari is the strictest about this. If your service worker answers a range request with a full cached <code>200</code> response, some browsers play it, some refuse, and seeking can break.</p>
<p>The fix is to store the full response in the cache, then slice it when a range is requested:</p>
<pre><code class="language-js">async function rangeResponse(request, cached) {
  const header = request.headers.get("range");
  if (!header) return cached;

  const buf = await cached.arrayBuffer();
  const size = buf.byteLength;
  const match = /bytes=(\d*)-(\d*)/.exec(header);
  if (!match) return cached;

  let start = match[1] ? Number(match[1]) : 0;
  let end = match[2] ? Number(match[2]) : size - 1;
  if (!match[1] &amp;&amp; match[2]) {        // "bytes=-500" means the last 500 bytes
    start = size - Number(match[2]);
    end = size - 1;
  }
  if (start &gt;= size || end &gt;= size || start &gt; end) {
    return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
  }

  return new Response(buf.slice(start, end + 1), {
    status: 206,
    headers: {
      "Content-Type": cached.headers.get("Content-Type") || "audio/mpeg",
      "Content-Range": `bytes ${start}-${end}/${size}`,
      "Content-Length": String(end - start + 1),
      "Accept-Ranges": "bytes",
    },
  });
}
</code></pre>
<p>Short clips make this cheap: reading a small file into memory to slice it is nothing.</p>
<p>There's a related trap when <em>filling</em> the cache. If you cache the network response to a range request, you might store a <code>206</code> with only part of the file. The Cache API rejects <code>206</code> responses in <code>put()</code>, so fetch the full file separately without a <code>Range</code> header:</p>
<pre><code class="language-js">async function fetchFull(url) {
  const res = await fetch(url, { mode: "cors", credentials: "omit" });
  if (!res.ok || res.status !== 200) throw new Error(`Bad status ${res.status}`);
  return res;
}
</code></pre>
<h2>Gotcha 2: Opaque responses eat your quota</h2>
<p>If your audio is on a CDN and you fetch it with <code>mode: "no-cors"</code>, you get an <strong>opaque response</strong>. You can cache it, but you can't read its status or size, and Chromium-based browsers count each opaque response against your quota as a padded size that's far bigger than the real file. Cache a few hundred opaque clips and you can hit the limit long before the actual bytes would.</p>
<p>Opaque responses also can't be sliced for range requests, since you can't read the body.</p>
<p>So: <strong>serve audio with CORS headers</strong> (<code>Access-Control-Allow-Origin</code> on the CDN) and fetch with <code>mode: "cors"</code>. If the <code>&lt;audio&gt;</code> elements on the page load from that origin too, add <code>crossorigin="anonymous"</code> so the page's requests and the service worker's requests match.</p>
<h2>The fetch handler</h2>
<p>Putting it together: serve audio from either cache if present, otherwise go to the network and cache the full file in the background.</p>
<pre><code class="language-js">self.addEventListener("fetch", (event) =&gt; {
  const url = new URL(event.request.url);
  if (!url.pathname.startsWith("/audio/")) return; // let other requests pass

  event.respondWith(handleAudio(event));
});

async function handleAudio(event) {
  const { request } = event;
  const key = new Request(request.url); // ignore headers like Range in the cache key

  const cached = (await caches.match(key, { cacheName: FAVS })) ||
                 (await caches.match(key, { cacheName: CLIPS }));
  if (cached) return rangeResponse(request, cached);

  // Not cached: answer from the network now, fill the cache in the background
  event.waitUntil(
    fetchFull(request.url)
      .then(async (res) =&gt; {
        const cache = await caches.open(CLIPS);
        await cache.put(key, res);
        await trimCache(CLIPS, MAX_CLIPS);
      })
      .catch(() =&gt; {}) // offline or failed: nothing to cache
  );

  return fetch(request);
}
</code></pre>
<p>This downloads a clip twice on first play (once for playback, once for the cache). For tiny clips that's an acceptable trade for simpler code. If it bothers you, fetch the full file first, cache a clone, and answer the range request from it.</p>
<h2>Trimming the runtime cache</h2>
<p>The Cache API has no built-in eviction policy, so cap it yourself. <code>cache.keys()</code> returns requests in insertion order, which gives you a simple "oldest first" trim:</p>
<pre><code class="language-js">async function trimCache(name, max) {
  const cache = await caches.open(name);
  const keys = await cache.keys();
  for (let i = 0; i &lt; keys.length - max; i++) {
    await cache.delete(keys[i]);
  }
}
</code></pre>
<p>To make it closer to "least recently used", delete and re-put an entry when it's served from cache, so it moves to the end. For a soundboard, insertion order is usually good enough.</p>
<h2>Favourites: cache on purpose</h2>
<p>When the user favourites a clip, the page tells the service worker:</p>
<pre><code class="language-js">// page
async function onFavourite(clip) {
  const reg = await navigator.serviceWorker.ready;
  reg.active?.postMessage({ type: "fav-add", url: clip.audioUrl });
}

// sw.js
self.addEventListener("message", (event) =&gt; {
  const { type, url } = event.data || {};
  if (type === "fav-add") {
    event.waitUntil(fetchFull(url).then(async (res) =&gt; (await caches.open(FAVS)).put(url, res)));
  }
  if (type === "fav-remove") {
    event.waitUntil(caches.open(FAVS).then((c) =&gt; c.delete(url)));
  }
});
</code></pre>
<p>Favourites stay out of <code>trimCache</code>, so the clips someone cares about survive even after they've played hundreds of others.</p>
<h2>Check your storage, and ask to keep it</h2>
<p>Browsers can evict a site's storage when the device is low on space. Two APIs help:</p>
<pre><code class="language-js">const { usage, quota } = await navigator.storage.estimate();
console.log(`Using ${(usage / 1e6).toFixed(1)} MB of ~${(quota / 1e6).toFixed(0)} MB`);

if (navigator.storage.persist &amp;&amp; !(await navigator.storage.persisted())) {
  const granted = await navigator.storage.persist();
  console.log("Persistent storage:", granted);
}
</code></pre>
<p><code>persist()</code> is a request, not a guarantee. Browsers decide based on their own heuristics. A good moment to ask is right after someone favourites their first few clips, when keeping them offline is obviously useful.</p>
<h2>Cleaning up on update</h2>
<p>When you change the audio cache format, bump the version and delete old caches in <code>activate</code>:</p>
<pre><code class="language-js">self.addEventListener("activate", (event) =&gt; {
  const keep = new Set([SHELL, CLIPS, FAVS]);
  event.waitUntil(
    caches.keys().then((names) =&gt; Promise.all(names.filter((n) =&gt; !keep.has(n)).map((n) =&gt; caches.delete(n))))
  );
});
</code></pre>
<p>Be careful bumping <code>FAVS</code>: deleting it throws away the user's offline favourites. Migrate instead, copying entries from the old cache into the new one before deleting it.</p>
<h2>Testing</h2>
<ul>
<li>In DevTools, set the network to Offline, then tap a cached clip and an uncached one. The first should play; the second should fail gracefully (show a small "not available offline" state, not a frozen button).</li>
<li>Test seeking and replaying on Safari, where range handling is strictest.</li>
<li>Watch the storage panel while playing many clips to confirm the cap works.</li>
</ul>
<h2>Recap</h2>
<ul>
<li>Precache the shell, runtime-cache played clips with a cap, and cache favourites explicitly.</li>
<li>Answer <code>Range</code> requests with proper <code>206</code> slices from the full cached file.</li>
<li>Never <code>put()</code> a <code>206</code>; fetch the full file for the cache.</li>
<li>Use CORS, not <code>no-cors</code>, so you avoid opaque-response quota padding.</li>
<li>Request persistent storage at a moment that makes sense to the user.</li>
</ul>
<p>Offline isn't a big feature for a soundboard. It's the difference between a button that works everywhere and one that quietly doesn't on the bus.</p>
<hr />
<p><em>Abid Ali is a full-stack web developer in Pakistan and the builder of <a href="https://www.memetap.pk/">Meme Tap</a>, a free meme soundboard and browser meme tools.</em></p>
]]></content:encoded></item><item><title><![CDATA[One Article, Five Different Link Previews: og:url, Canonical Links and UTM Parameters Done Right]]></title><description><![CDATA[You publish an article. The newsletter links to it with ?utm_source=newsletter. Someone shares it from their phone as http://example.com/blog/post/. Your social scheduler adds its own tracking paramet]]></description><link>https://abidali671.hashnode.dev/one-article-five-different-link-previews-og-url-canonical-links-and-utm-parameters-done-right</link><guid isPermaLink="true">https://abidali671.hashnode.dev/one-article-five-different-link-previews-og-url-canonical-links-and-utm-parameters-done-right</guid><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[marketing]]></category><category><![CDATA[HTML]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Sat, 17 Oct 2026 09:49:40 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/b319b369-af64-46d6-b54e-aab492887e65.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You publish an article. The newsletter links to it with <code>?utm_source=newsletter</code>. Someone shares it from their phone as <code>http://example.com/blog/post/</code>. Your social scheduler adds its own tracking parameters. A colleague copies the address from an old tab that still has <code>?ref=home</code>. By the end of the week the same article has been shared under five different URLs, and the previews don't match: one shows last week's image, one shows the right card, one shows your homepage.</p>
<p>This isn't the platforms being random. Link previews are built and cached <strong>per URL</strong>, and your site is quietly telling crawlers that one article has many addresses. The fix is to give every piece of content exactly one address and make every signal agree on it: redirects, <code>&lt;link rel="canonical"&gt;</code>, <code>og:url</code>, and your sitemap.</p>
<h2>Why the URL matters so much to a preview</h2>
<p>When a chat app or social network builds a card, it fetches the URL it was given, reads the tags and stores the result. Two URLs that differ by a query string or a trailing slash are, to a cache, simply two different keys. Fix the image on your page and one of those cached cards might refresh while the other keeps showing the old one for days.</p>
<p><code>og:url</code> is your chance to say which address is the real one. Facebook's sharing documentation, for example, describes <code>og:url</code> as the canonical URL for the object, the one it should treat as the main address. On OG Scope's <a href="https://www.ogscope.pk/facebook-link-preview">Facebook link preview</a> estimate, the domain line on the card is taken from <code>og:url</code>, which is a quick way to notice when it's wrong.</p>
<p>Not every platform honours <code>og:url</code> the same way, so you can't rely on it alone. That's why the goal is agreement: if the redirect, the canonical link and <code>og:url</code> all point at the same address, every crawler ends up in the same place no matter which variant it was given.</p>
<h2>Decide your one true URL format</h2>
<p>Write the rules down once. For example:</p>
<ul>
<li><strong>Scheme:</strong> always <code>https</code>.</li>
<li><strong>Host:</strong> <code>www.example.com</code> (or the bare domain, but pick one).</li>
<li><strong>Trailing slash:</strong> never, except the root.</li>
<li><strong>Case:</strong> lowercase paths.</li>
<li><strong>Query string:</strong> only parameters that change the content (pagination, maybe language). Never tracking parameters.</li>
</ul>
<p>Then enforce it in three places.</p>
<h2>1. Redirect the variants</h2>
<p>Anything that isn't the canonical format should <code>301</code> to the version that is. Most hosts handle <code>http</code> → <code>https</code> and bare → <code>www</code> in their domain settings. Trailing slashes and casing are usually yours to handle. In Next.js, <code>trailingSlash: false</code> is the default, and a small middleware can lowercase paths:</p>
<pre><code class="language-ts">// middleware.ts
import { NextResponse, type NextRequest } from "next/server";

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
  if (pathname !== pathname.toLowerCase()) {
    const url = req.nextUrl.clone();
    url.pathname = pathname.toLowerCase();
    return NextResponse.redirect(url, 301);
  }
}

export const config = { matcher: ["/blog/:path*"] };
</code></pre>
<p><strong>Don't redirect away tracking parameters.</strong> Analytics tools need to see <code>utm_*</code> on the landing request. Instead, leave them in the address bar and let the canonical signals say "the real page is the clean one".</p>
<h2>2. Build canonical URLs with one helper</h2>
<p>The most common bug I see is canonical and <code>og:url</code> being built in different places with different logic: one strips the query string, one doesn't; one adds a trailing slash, one doesn't. Use a single function:</p>
<pre><code class="language-ts">// lib/canonical.ts
const SITE = "https://www.example.com";

// Query params that genuinely change the content of a page
const KEEP_PARAMS = new Set(["page", "lang"]);

export function canonicalUrl(pathOrUrl: string): string {
  const url = new URL(pathOrUrl, SITE);

  url.protocol = "https:";
  url.host = new URL(SITE).host;
  url.hash = "";
  url.pathname = url.pathname.toLowerCase().replace(/\/+$/, "") || "/";

  const kept = new URLSearchParams();
  [...url.searchParams.keys()]
    .filter((k) =&gt; KEEP_PARAMS.has(k))
    .sort()
    .forEach((k) =&gt; kept.set(k, url.searchParams.get(k)!));

  // page=1 is the same content as no page parameter
  if (kept.get("page") === "1") kept.delete("page");

  url.search = kept.toString();
  return url.toString();
}
</code></pre>
<p>Tracking parameters like <code>utm_source</code>, <code>fbclid</code> and <code>gclid</code> are dropped automatically because they're not on the allowlist. An allowlist is safer than a blocklist here: new tracking parameters appear all the time.</p>
<p>Then use the helper for both tags. In the App Router:</p>
<pre><code class="language-tsx">// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { canonicalUrl } from "@/lib/canonical";
import { getPost } from "@/lib/posts";

export async function generateMetadata(
  { params }: { params: Promise&lt;{ slug: string }&gt; }
): Promise&lt;Metadata&gt; {
  const { slug } = await params;
  const post = await getPost(slug);
  const url = canonicalUrl(`/blog/${slug}`);

  return {
    title: post.title,
    alternates: { canonical: url },
    openGraph: { url, title: post.title, description: post.excerpt, images: [post.ogImage] },
  };
}
</code></pre>
<p>In plain HTML the result is just two lines that must match exactly:</p>
<pre><code class="language-html">&lt;link rel="canonical" href="https://www.example.com/blog/post-title" /&gt;
&lt;meta property="og:url" content="https://www.example.com/blog/post-title" /&gt;
</code></pre>
<h2>3. Sitemap and internal links use the same URL</h2>
<p>Your sitemap should list the canonical URLs only, and your own internal links should point at them too. If your navigation links to <code>/Blog/Post-Title/</code> and every click causes a redirect, you're making the inconsistent version the one people copy.</p>
<h2>The template bug that turns every share into your homepage</h2>
<p>A very common one: a layout sets <code>og:url</code> to the site root, and individual pages never override it.</p>
<pre><code class="language-html">&lt;!-- in the shared &lt;head&gt; template --&gt;
&lt;meta property="og:url" content="https://www.example.com/" /&gt;
</code></pre>
<p>Now every article tells crawlers "I am the homepage". Depending on the platform, shares of any article may show the homepage card, or engagement gets attributed to the homepage. If your articles all preview with the same title, check <code>og:url</code> before anything else.</p>
<p>Similarly, don't hardcode <code>og:url</code> with the URL of the page you copied the template from. I've seen a whole site's <code>og:url</code> pointing at its "About" page.</p>
<h2>Parameters that <em>should</em> stay</h2>
<p>Not every query string is noise.</p>
<ul>
<li><strong>Pagination:</strong> <code>/blog?page=2</code> is different content from <code>/blog</code>. Keep <code>page</code> (except <code>page=1</code>).</li>
<li><strong>Language:</strong> if <code>?lang=ur</code> serves different text, keep it, or better, move languages to paths like <code>/ur/...</code> and give each its own canonical.</li>
<li><strong>Filters and sorts:</strong> usually <em>not</em> separate canonical pages. <code>/products?sort=price</code> should canonicalize to <code>/products</code>.</li>
<li><strong>Product variants:</strong> depends. If <code>?color=red</code> changes the main image and title, it might deserve its own preview. If it only preselects a dropdown, canonicalize to the base product.</li>
</ul>
<p>The test: would you want this exact variant to have its own card when shared? If not, it shouldn't have its own <code>og:url</code>.</p>
<h2>A quick script to catch drift</h2>
<p>Run this against a few URL variants after deploys. It fetches each one and checks that the final URL, the canonical and <code>og:url</code> all agree:</p>
<pre><code class="language-ts">// scripts/check-canonical.ts
const variants = [
  "http://example.com/blog/post-title",
  "https://www.example.com/blog/post-title/",
  "https://www.example.com/Blog/Post-Title",
  "https://www.example.com/blog/post-title?utm_source=newsletter&amp;utm_medium=email",
];

const pick = (html: string, re: RegExp) =&gt; html.match(re)?.[1] ?? "(missing)";

for (const v of variants) {
  const res = await fetch(v, { redirect: "follow" });
  const html = await res.text();
  const canonical = pick(html, /&lt;link[^&gt;]+rel=["']canonical["'][^&gt;]+href=["']([^"']+)/i);
  const ogUrl = pick(html, /&lt;meta[^&gt;]+property=["']og:url["'][^&gt;]+content=["']([^"']+)/i);
  const ok = canonical === ogUrl;
  console.log(ok ? "OK  " : "DIFF", v, "\n     final:", res.url, "\n     canonical:", canonical, "\n     og:url:", ogUrl);
}
</code></pre>
<p>Run it with <code>npx tsx scripts/check-canonical.ts</code>. The regexes assume the attribute order your framework emits; swap in an HTML parser if your markup varies.</p>
<h2>Checklist</h2>
<ul>
<li> One documented URL format: scheme, host, slash, case.</li>
<li> Variants <code>301</code> to the canonical format (except tracking parameters).</li>
<li> Canonical link and <code>og:url</code> come from the same helper and match exactly.</li>
<li> Query parameters on an allowlist, not a blocklist.</li>
<li> No template-level <code>og:url</code> pointing at the homepage.</li>
<li> Sitemap and internal links use canonical URLs only.</li>
</ul>
<p>When the URL is consistent, the preview stops being a lottery. Fix the page once, and the card updates in one place instead of five.</p>
<hr />
<p><em>Abid Ali is a web developer in Pakistan who builds small, free tools, including <a href="https://www.ogscope.pk/">OG Scope</a>, an Open Graph and link preview checker.</em></p>
]]></content:encoded></item><item><title><![CDATA[Replay, Don't Patch: Keeping Elo Ratings Correct When Old Results Change]]></title><description><![CDATA[Elo ratings have an awkward property: they depend on order. A player's rating after match 40 depends on their rating after match 39, which depends on match 38, and so on back to the start. So if someo]]></description><link>https://abidali671.hashnode.dev/replay-don-t-patch-keeping-elo-ratings-correct-when-old-results-change</link><guid isPermaLink="true">https://abidali671.hashnode.dev/replay-don-t-patch-keeping-elo-ratings-correct-when-old-results-change</guid><category><![CDATA[Next.js]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[Testing]]></category><category><![CDATA[SQLite]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Fri, 16 Oct 2026 10:12:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/08bcac8a-e6d9-4681-803d-e20fd655bb63.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Elo ratings have an awkward property: they depend on order. A player's rating after match 40 depends on their rating after match 39, which depends on match 38, and so on back to the start. So if someone fixes a typo in a score from three weeks ago, every rating after that match is now wrong.</p>
<p>Most hobby leaderboards ignore this. They apply a rating change when a match is saved and hope nobody ever edits anything. <a href="https://github.com/amirsaeed671/fun-board">fun-board</a>, a small Next.js app by <a href="https://github.com/amirsaeed671">Amir Ali</a>, handles it properly, and the way it does that is worth a look even if you never build a leaderboard.</p>
<h2>What fun-board is</h2>
<p>fun-board is a multi-tenant leaderboard for FIFA nights with friends. Each registered user gets their own board with players, matches, knockout or round-robin tournaments, a points table and Elo ratings. Every board also has a public, read-only link you can drop in a group chat. You can see the seeded demo at <a href="https://fun-board.vercel.app/l/demo">fun-board.vercel.app/l/demo</a>.</p>
<p>The stack, from the README: Next.js 16 (App Router), React 19, TypeScript, Tailwind v4, Turso/libSQL with raw SQL (no ORM), Auth.js v5, Zod for every mutation, and Vitest for the ranking logic.</p>
<p>The interesting part isn't the UI. It's one design decision in <code>lib/recompute.ts</code>.</p>
<h2>The Elo maths is the easy part</h2>
<p>The rating code in <code>lib/elo.ts</code> is the textbook formula. Everyone starts at 1000 and K is 32:</p>
<pre><code class="language-ts">const K = 32

export function expectedScore(ratingA: number, ratingB: number): number {
  return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400))
}

export function newRating(current: number, expected: number, actual: number): number {
  return Math.round(current + K * (actual - expected))
}
</code></pre>
<p>Nothing clever here, and that's fine. The hard part is deciding <em>when</em> to run it.</p>
<h2>Why patching ratings breaks</h2>
<p>The obvious approach is to update two players' ratings each time a match is saved. That works until you need to:</p>
<ul>
<li><p>edit the score of an older match,</p>
</li>
<li><p>delete a match that was entered by mistake, or</p>
</li>
<li><p>change the date of a match so it moves earlier in the timeline.</p>
</li>
</ul>
<p>With patch-style updates, each of these needs its own undo logic, and you need to work out which later matches to adjust and in what order. It's easy to get wrong, and once the stored numbers drift, nothing tells you.</p>
<h2>Replay instead: ratings as a function of history</h2>
<p>Amir's answer is to never patch. Ratings, win/draw/loss counts, goals and points are all treated as <em>derived data</em>. The source of truth is the list of matches. Anything computed from it can be thrown away and rebuilt.</p>
<p>The core is a pure function that takes matches in chronological order and folds over them:</p>
<pre><code class="language-ts">/**
 * Pure Elo + aggregate replay. Matches MUST be in chronological order.
 */
export function replayElo(matches: ReplayMatch[]): {
  snapshots: ReplaySnapshot[]
  players: Map&lt;string, PlayerAgg&gt;
} {
  // ...
  for (const m of matches) {
    const home = get(m.home_player_id)
    const away = get(m.away_player_id)
    const homeBefore = home.elo
    const awayBefore = away.elo
    const result =
      m.home_score &gt; m.away_score ? "home" : m.away_score &gt; m.home_score ? "away" : "draw"
    const { homeAfter, awayAfter } = calculateEloChanges(homeBefore, awayBefore, result)
    home.elo = homeAfter
    away.elo = awayAfter
    // ...goals, wins/draws/losses, points (3/1/0)
    snapshots.push({ matchId: m.id, homeBefore, homeAfter, awayBefore, awayAfter })
  }
  return { snapshots, players }
}
</code></pre>
<p>It touches no database and has no hidden state. It also returns a before/after snapshot for every match, which is what feeds each player's Elo history chart.</p>
<p>A side effect I liked: in knockout ties decided on penalties, the stored score is level, so Elo treats the game as a draw. The shootout only decides who goes through. That's a reasonable rule, and it falls out of the design without any special-case code.</p>
<h2>One batch rebuilds everything</h2>
<p><code>recomputeUser(userId)</code> wraps the pure function. It loads that user's matches with <code>ORDER BY played_at ASC, rowid ASC</code> (so two matches with the same timestamp keep a stable order), runs <code>replayElo</code>, and builds a list of SQL statements:</p>
<ol>
<li><p>Reset every player on the board to 1000 / zero, then write the replayed aggregates.</p>
</li>
<li><p>Write the before/after Elo snapshot onto each match row.</p>
</li>
<li><p>Delete and rebuild <code>elo_history</code> from scratch.</p>
</li>
<li><p>Recompute tournament participant standings from tournament matches.</p>
</li>
</ol>
<p>Then it sends them all at once:</p>
<pre><code class="language-ts">await db.batch(stmts, "write")
</code></pre>
<p>A libSQL batch runs as a single transaction, so a reader never sees half-rebuilt standings. Every mutation endpoint ends the same way: the match <code>PATCH</code> and <code>DELETE</code> handlers write their change and then call <code>recomputeUser(userId)</code>. There's one code path for "make the numbers right", and it gets used every time.</p>
<h2>Downstream effects in tournaments</h2>
<p>Knockout brackets have the same order problem. If you change the result of a quarter-final, the semi-final pairing may now be wrong. The match <code>PATCH</code> route deals with that directly:</p>
<pre><code class="language-ts">if (isKnockout) {
  // Downstream rounds may now be invalid — drop and rebuild them.
  await db.execute({
    sql: "DELETE FROM tournament_matches WHERE tournament_id = ? AND round &gt; ?",
    args: [tm.tournament_id as string, tm.round as number],
  })
  // ...reset champion/runner-up and remove the bonus points they earned
}
</code></pre>
<p>After that it recomputes and calls <code>advanceKnockout</code> to pair the winners again. It's the same idea as before: throw away what depends on the changed fact, then derive it again.</p>
<h2>Tests that check properties, not just examples</h2>
<p>Because <code>replayElo</code> is pure, it's easy to test, and the tests in <code>lib/recompute.test.ts</code> check the properties that matter rather than a couple of hand-picked numbers:</p>
<pre><code class="language-ts">it("chains Elo across matches using the updated rating", () =&gt; {
  const { players } = replayElo([
    mk("m1", "A", "B", 1, 0), // A 1000-&gt;1016, B 1000-&gt;984
    mk("m2", "A", "B", 1, 0), // A now favourite, smaller gain
  ])
  const a = players.get("A")!
  const b = players.get("B")!
  expect(a.elo).toBeGreaterThan(1016)
  expect(a.elo).toBeLessThan(1032)
  expect(a.elo + b.elo).toBe(2000) // zero-sum
})
</code></pre>
<p>There are also tests for draws between equal players leaving ratings unchanged, goal totals, points-table tiebreaks (points, then goal difference, then goals scored, then head-to-head), streaks and bracket pairing. I cloned the repo and ran <code>pnpm test</code>: 4 files, 36 tests, all passing in well under a second.</p>
<h2>Multi-tenancy without trusting the UI</h2>
<p>Two smaller details are worth mentioning.</p>
<p><strong>Ownership is checked in SQL.</strong> Every row belongs to a user (<code>players.user_id</code>, <code>matches.recorded_by</code>, <code>tournaments.created_by</code>), and every write checks that the caller owns it, for example <code>SELECT * FROM matches WHERE id = ? AND recorded_by = ?</code>. The README states the rule outright: read-only access "is enforced server-side, not just by hiding buttons." Public boards are reached through a share slug, and the slug generator takes an injectable RNG so its tests are deterministic.</p>
<p><strong>Migrations that survive production.</strong> The schema runs itself on first use. One commit fixed a real deployment problem: hosted Turso over HTTP rejects statements like <code>PRAGMA journal_mode=WAL</code>, so the migration filters them out with a comment explaining why. New columns are added through an <code>ensureColumn</code> helper that checks <code>PRAGMA table_info</code> first, so older databases upgrade in place.</p>
<h2>Trade-offs</h2>
<p>Replaying every match on every edit is O(n) in the size of a board's history. For a friend group recording a few hundred matches, that's a few hundred statements in one batch, which is nothing. A league with millions of games would need snapshots or incremental replay from the edited match onward. For this app, Amir picked the simplest design that can't drift, and the code makes the trade-off easy to change later because the pure part is separate from the database part.</p>
<h2>What this says about the developer</h2>
<p>Plenty of developers can write the Elo formula. What I look for is whether someone sees the edge cases before a user hits them: edits, deletions, reordering, a bracket that no longer makes sense. fun-board handles all of them with one idea applied consistently, backed by tests and an honest README that lists what v0 doesn't do yet.</p>
<p>That matches how Amir describes his client work on <a href="https://www.upwork.com/freelancers/~015a1f2c7e3521efe3">Upwork</a>: building and taking over Next.js and TypeScript SaaS apps, in small pull requests that he reviews himself.</p>
<h2>Work with Amir</h2>
<p>Amir Ali is a full-stack developer in Karachi, Pakistan, working remotely with React, Next.js, TypeScript, Node.js and Supabase/Postgres. He's open to full-time roles and freelance projects.</p>
<ul>
<li><p><strong>GitHub:</strong> <a href="https://github.com/amirsaeed671">github.com/amirsaeed671</a> (fun-board is <a href="https://github.com/amirsaeed671/fun-board">here</a>)</p>
</li>
<li><p><strong>Upwork:</strong> <a href="https://www.upwork.com/freelancers/~015a1f2c7e3521efe3">Amir A., Full Stack SaaS Developer</a></p>
</li>
<li><p><strong>LinkedIn:</strong> <a href="https://www.linkedin.com/in/amirsaeed671">linkedin.com/in/amirsaeed671</a></p>
</li>
<li><p><strong>Portfolio:</strong> <a href="https://amir-ali.me">amir-ali.me</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[The Freelance Developer's Handover Kit: README, .env.example, Access Transfer and a One-Command Deploy That Get You Rehired]]></title><description><![CDATA[Most freelance web projects don't end. They fade. The last milestone gets approved, you send a zip or a repo invite, the client says "thanks!", and that's it. Three months later they need a change, ca]]></description><link>https://abidali671.hashnode.dev/the-freelance-developer-s-handover-kit-readme-env-example-access-transfer-and-a-one-command-deploy-that-get-you-rehired</link><guid isPermaLink="true">https://abidali671.hashnode.dev/the-freelance-developer-s-handover-kit-readme-env-example-access-transfer-and-a-one-command-deploy-that-get-you-rehired</guid><category><![CDATA[Freelancing]]></category><category><![CDATA[webdev]]></category><category><![CDATA[documentation]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Career]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Fri, 16 Oct 2026 09:45:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/d1c48951-0ad9-437b-804a-5827768eeda2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most freelance web projects don't end. They fade. The last milestone gets approved, you send a zip or a repo invite, the client says "thanks!", and that's it. Three months later they need a change, can't remember who built the site or where it's hosted, and hire whoever answers first.</p>
<p>The handover is the most underrated moment in client work. It's the last thing the client experiences, and it decides whether they come back to you or start over with someone else. It's also where developers are at their most tired and most likely to cut corners.</p>
<p>This post is the handover kit I'd recommend to any freelance developer: a short checklist, a client-friendly README, a <code>.env.example</code> that actually explains things, a clean access transfer, and a one-command deploy. Templates included.</p>
<h2>Why handover is a sales moment</h2>
<p>There's a business reason to care beyond being tidy. A client who just received working software is the warmest lead you'll ever have. They've seen your work and they trust you. Digital Dialogue's guide on <a href="https://www.digitaldialogue.pk/blogs/freelancing/customer-follow-up-get-the-next-job-from-the-same-upwork-client">customer follow-up and getting the next job from the same Upwork client</a> makes this point well: close the contract cleanly, ask for the review at the right time, and make one specific follow-on offer instead of a vague "let me know if you need anything".</p>
<p>A clean handover is what makes that follow-on offer land. A messy one makes it awkward.</p>
<h2>The checklist</h2>
<p>Copy this into your project template and tick it off before you submit the final milestone:</p>
<pre><code class="language-markdown">## Handover checklist

### Code
- [ ] Repo transferred to client's account (or client added as owner)
- [ ] Default branch is clean; no stray debug code or TODOs that matter
- [ ] README.md written for a *future developer*, not for me
- [ ] HANDOVER.md written for the *client*, in plain language
- [ ] .env.example lists every variable with a comment

### Access
- [ ] Domain, hosting, DNS, email, analytics owned by the client's accounts
- [ ] My personal accounts removed or downgraded
- [ ] Every password I created rotated or handed over via a password manager share
- [ ] API keys in the client's name (not mine)

### Running it
- [ ] One-command local setup verified on a clean machine
- [ ] One-command deploy documented
- [ ] Backups: what's backed up, where, how to restore

### Closing
- [ ] Short walkthrough video (5–10 min)
- [ ] Final message: what was delivered, what's not included, one next-step offer
</code></pre>
<p>The <strong>Access</strong> block is the one that bites. Developers often set up hosting, analytics or a transactional email service on their own accounts "to move fast". That's fine during the build and a time bomb after it. If your card is on the hosting account, the client doesn't own their own website.</p>
<h2>README.md: for the next developer</h2>
<p>Write it as if the next person to open the repo is a stranger with no context, because it might be. That person might also be you in eight months.</p>
<pre><code class="language-markdown"># Acme Plumbing — website

Next.js site deployed on Vercel. Contact form sends email through Resend.

## Requirements
- Node 20+
- pnpm 9+

## Local setup
pnpm install
cp .env.example .env.local   # fill in values (see comments in the file)
pnpm dev                     # http://localhost:3000

## Deploy
Pushing to `main` deploys to production automatically.
Preview deploys are created for every pull request.

## Where things live
- Pages: `app/`
- Service content: `content/services/*.md` (edit text here, no code needed)
- Contact form handler: `app/api/contact/route.ts`

## Known limitations
- Gallery images are not resized automatically; upload images under 500 KB.
</code></pre>
<p>The "Known limitations" section is a gift to the next developer and a quiet protection for you. It shows these were known trade-offs, agreed within scope, not mistakes.</p>
<h2>HANDOVER.md: for the client</h2>
<p>The client won't read your README. Give them a separate, plain-language document:</p>
<pre><code class="language-markdown"># Your website: how it works

**Your site:** https://acmeplumbing.example
**Where it's hosted:** Vercel (account: owner@acme.example)
**Your domain:** registered at Namecheap (account: owner@acme.example), renews every March

## Changing text
Service descriptions live in your GitHub repository under content/services.
The walkthrough video (link) shows how to edit one in the browser.

## If the contact form stops sending
1. Check the Resend dashboard for errors.
2. Check the spam folder of owner@acme.example.

## What I'm still able to help with
Small edits, new pages, or a monthly care plan. Just message me.
</code></pre>
<p>Name the accounts and renewal dates. The single most common post-launch emergency I've seen isn't a bug. It's an expired domain nobody knew was renewing on someone else's card.</p>
<h2>.env.example that actually explains things</h2>
<p>A list of variable names is not documentation. Comment each one with what it's for and where to get it:</p>
<pre><code class="language-bash"># .env.example

# Public site URL, used for canonical links and social share images.
NEXT_PUBLIC_SITE_URL=https://acmeplumbing.example

# Resend API key for the contact form.
# Create at: resend.com → API Keys (use the client's account, not the developer's).
RESEND_API_KEY=

# Where contact form messages are delivered.
CONTACT_TO_EMAIL=owner@acme.example

# Optional: analytics. Leave empty to disable.
NEXT_PUBLIC_ANALYTICS_ID=
</code></pre>
<p>Add a tiny guard so a missing variable fails loudly at startup instead of silently in production:</p>
<pre><code class="language-ts">// lib/env.ts
const required = ["NEXT_PUBLIC_SITE_URL", "RESEND_API_KEY", "CONTACT_TO_EMAIL"] as const;

for (const key of required) {
  if (!process.env[key]) {
    throw new Error(`Missing environment variable: ${key}. See .env.example`);
  }
}

export const env = {
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
  resendKey: process.env.RESEND_API_KEY!,
  contactTo: process.env.CONTACT_TO_EMAIL!,
};
</code></pre>
<h2>One-command setup and deploy</h2>
<p>If the site isn't on a platform that deploys from git automatically, give the client (or the next dev) a script instead of a page of instructions:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# scripts/deploy.sh: build and deploy to the VPS
set -euo pipefail

: "${DEPLOY_HOST:?Set DEPLOY_HOST, e.g. user@1.2.3.4}"
: "${DEPLOY_PATH:=/var/www/acme}"

pnpm install --frozen-lockfile
pnpm build

rsync -az --delete .next public package.json pnpm-lock.yaml next.config.mjs \
  "$DEPLOY_HOST:$DEPLOY_PATH/"

ssh "$DEPLOY_HOST" "cd $DEPLOY_PATH &amp;&amp; pnpm install --prod --frozen-lockfile &amp;&amp; pm2 reload acme"
echo "Deployed to $DEPLOY_HOST"
</code></pre>
<p>Then test it the honest way: clone the repo into a fresh folder (or a throwaway container), follow only the README, and see if you get a running site. Every step you had to "just know" goes into the README.</p>
<h2>Transfer access without leaking secrets</h2>
<p>A few rules that keep both sides safe:</p>
<ul>
<li><strong>Never paste passwords into the project chat.</strong> Use a password manager's share feature, or have the client create the account and invite you.</li>
<li><strong>Rotate what you created.</strong> Any key or password you generated during the build should be rotated after handover so only the client holds it.</li>
<li><strong>Downgrade yourself.</strong> Remove your admin access, or ask the client to, once they confirm everything works. Offer to stay as a limited collaborator if they want ongoing help.</li>
</ul>
<p>Clients rarely ask for this. They remember when you offer it.</p>
<h2>The final message</h2>
<p>Close with one message in the contract thread:</p>
<pre><code class="language-text">Everything from the final milestone is live at https://acmeplumbing.example.

Delivered: 5 pages, contact form, deployment, 2 revision rounds (both used).
Handover: repo transferred, HANDOVER.md + walkthrough video (link), all
accounts in your name, my access removed from hosting.

One idea for later: a "Service areas" page would help people searching for
plumbers in specific neighbourhoods. I can build it as a new milestone for
$120, about 2 days.

Thank you, this was a great project to work on.
</code></pre>
<p>What's delivered, what's handed over, one specific next step with a price. No pressure, no vague "happy to help in future".</p>
<h2>Make it a template</h2>
<p>You don't need to write any of this from scratch per project. Keep a <code>handover/</code> folder in your starter repo with the checklist, README skeleton, HANDOVER template and env guard. It adds maybe two hours to a project, and it's the two hours most likely to bring the next contract.</p>
<hr />
<p><em>Abid Ali is a full-stack web developer who freelances from Pakistan. He writes practical freelancing guides at <a href="https://www.digitaldialogue.pk/">Digital Dialogue</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Building a Meme Caption Generator in the Browser with Canvas: Text Wrapping, Draggable Boxes and PNG/JPG Export (No Uploads)]]></title><description><![CDATA[A meme caption tool looks like a weekend project. Put an image on a canvas, write some white text with a black outline, download. Then real people use it. Someone types a caption that's too long for t]]></description><link>https://abidali671.hashnode.dev/building-a-meme-caption-generator-in-the-browser-with-canvas-text-wrapping-draggable-boxes-and-png-jpg-export-no-uploads</link><guid isPermaLink="true">https://abidali671.hashnode.dev/building-a-meme-caption-generator-in-the-browser-with-canvas-text-wrapping-draggable-boxes-and-png-jpg-export-no-uploads</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[canvas]]></category><category><![CDATA[webdev]]></category><category><![CDATA[frontend]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Fri, 16 Oct 2026 09:44:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/48e72f56-c5ef-4446-90da-ee29a7f90c8e.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A meme caption tool looks like a weekend project. Put an image on a canvas, write some white text with a black outline, download. Then real people use it. Someone types a caption that's too long for the box. Someone uploads a 4000-pixel phone photo that comes out sideways. Someone picks a template from your server and the download button throws a security error. Someone writes in Roman Urdu with emoji and the outline looks like barbed wire.</p>
<p>This post walks through building a small, fully client-side caption generator with the Canvas 2D API, and the handful of details that make it feel solid. Everything runs in the browser, which is also a nice privacy story: the user's photo never leaves their device.</p>
<p>For a live reference of the finished idea, Meme Tap has a free <a href="https://www.memetap.pk/tools/meme-caption-generator">meme caption generator</a>: upload an image or pick a template, add Roman Urdu or English text, drag the boxes around, and download a PNG or JPG. The code below is my own minimal version of that pattern, not that tool's source.</p>
<h2>The shape of the app</h2>
<p>Keep state separate from drawing. The canvas is just a render target; the truth lives in a plain object:</p>
<pre><code class="language-js">const state = {
  image: null,            // ImageBitmap or HTMLImageElement
  boxes: [
    { id: "top",    text: "JAB CLIENT KAHE",      x: 0.5, y: 0.08, size: 0.09 },
    { id: "bottom", text: "BAS CHHOTA SA CHANGE", x: 0.5, y: 0.92, size: 0.09 },
  ],
  activeId: null,
};
</code></pre>
<p>Positions and font sizes are stored as <strong>fractions of the image size</strong>, not pixels. That one decision makes everything else easier: the preview can be any size on screen, and export at full resolution uses the same numbers.</p>
<h2>Loading the image (and the sideways photo problem)</h2>
<p>Use <code>createImageBitmap</code> for user files. It decodes off the main thread in most browsers and can respect EXIF orientation, which is where the "my photo is sideways" bug comes from:</p>
<pre><code class="language-js">async function loadFile(file) {
  if (!file.type.startsWith("image/")) throw new Error("Not an image");
  state.image = await createImageBitmap(file, { imageOrientation: "from-image" });
  render();
}

input.addEventListener("change", (e) =&gt; loadFile(e.target.files[0]));
</code></pre>
<p>No upload, no server: the <code>File</code> goes straight into the decoder.</p>
<h3>Templates from your own server</h3>
<p>Templates are different, because they're URLs. If they're on another origin (a CDN, for example), drawing them taints the canvas and <code>toBlob()</code> will throw. Two requirements:</p>
<pre><code class="language-js">function loadTemplate(url) {
  return new Promise((resolve, reject) =&gt; {
    const img = new Image();
    img.crossOrigin = "anonymous";   // must be set before src
    img.onload = () =&gt; resolve(img);
    img.onerror = reject;
    img.src = url;
  });
}
</code></pre>
<p>…and the server must respond with <code>Access-Control-Allow-Origin</code> for those image files. Forget either one and everything renders fine until the user hits Download.</p>
<h2>Rendering crisp text</h2>
<p>Set the canvas backing size to the image's real resolution and let CSS scale the element down for display. You get a sharp export for free, and the preview still fits the screen:</p>
<pre><code class="language-js">function render() {
  const { image } = state;
  if (!image) return;
  canvas.width = image.width;
  canvas.height = image.height;
  const ctx = canvas.getContext("2d");
  ctx.drawImage(image, 0, 0);
  for (const box of state.boxes) drawBox(ctx, box, image.width, image.height);
}
</code></pre>
<pre><code class="language-css">canvas { max-width: 100%; height: auto; touch-action: none; }
</code></pre>
<p><code>touch-action: none</code> matters later, for dragging on phones.</p>
<h3>The classic meme look</h3>
<p>The classic style is a heavy sans font, white fill and a thick black outline. Draw the stroke first, then the fill on top, and use <code>lineJoin = "round"</code>. Without it, sharp letter corners (and many emoji edges) produce those spiky "barbed wire" artefacts:</p>
<pre><code class="language-js">function drawLine(ctx, text, x, y, px) {
  ctx.font = `900 ${px}px Impact, "Arial Black", sans-serif`;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.lineJoin = "round";
  ctx.miterLimit = 2;
  ctx.lineWidth = Math.max(2, px * 0.12);
  ctx.strokeStyle = "#000";
  ctx.fillStyle = "#fff";
  ctx.strokeText(text, x, y);
  ctx.fillText(text, x, y);
}
</code></pre>
<p>If you use a web font, wait for it before the first render, or the first frame draws in a fallback font and the measurements are wrong:</p>
<pre><code class="language-js">await document.fonts.load('900 48px "Anton"');
</code></pre>
<h2>Word wrapping and auto-fit</h2>
<p>Canvas has no text wrapping. You write it yourself with <code>measureText</code>:</p>
<pre><code class="language-js">function wrap(ctx, text, maxWidth) {
  const words = text.split(/\s+/).filter(Boolean);
  const lines = [];
  let line = "";
  for (const w of words) {
    const test = line ? `${line} ${w}` : w;
    if (ctx.measureText(test).width &lt;= maxWidth || !line) line = test;
    else { lines.push(line); line = w; }
  }
  if (line) lines.push(line);
  return lines;
}
</code></pre>
<p>Then auto-fit: start at the requested size and shrink until the wrapped block fits in a maximum height. Users type long captions; it's kinder to shrink than to overflow off the image.</p>
<pre><code class="language-js">function drawBox(ctx, box, W, H) {
  const maxWidth = W * 0.92;
  const maxHeight = H * 0.3;
  let px = Math.round(box.size * H);
  let lines;
  do {
    ctx.font = `900 ${px}px Impact, "Arial Black", sans-serif`;
    lines = wrap(ctx, box.text.toUpperCase(), maxWidth);
    if (lines.length * px * 1.1 &lt;= maxHeight) break;
    px -= 2;
  } while (px &gt; 12);

  const lineHeight = px * 1.1;
  const startY = box.y * H - ((lines.length - 1) * lineHeight) / 2;
  lines.forEach((l, i) =&gt; drawLine(ctx, l, box.x * W, startY + i * lineHeight, px));

  box._bounds = {               // remember for hit-testing
    x: box.x * W - maxWidth / 2, y: startY - lineHeight / 2,
    w: maxWidth, h: lines.length * lineHeight,
  };
}
</code></pre>
<p>A note on <code>toUpperCase()</code>: it suits English and Roman Urdu captions. If you support Urdu script (Nastaliq), skip it, load a proper Urdu font, and set <code>ctx.direction = "rtl"</code>. Canvas handles the shaping, but only if the font has the glyphs.</p>
<h2>Draggable boxes with Pointer Events</h2>
<p>Pointer Events give you mouse, touch and pen in one API. The only conversion you need is from screen coordinates to canvas coordinates, because the canvas is CSS-scaled:</p>
<pre><code class="language-js">function toCanvas(e) {
  const r = canvas.getBoundingClientRect();
  return {
    x: (e.clientX - r.left) * (canvas.width / r.width),
    y: (e.clientY - r.top) * (canvas.height / r.height),
  };
}

let drag = null;

canvas.addEventListener("pointerdown", (e) =&gt; {
  const p = toCanvas(e);
  const hit = [...state.boxes].reverse().find(({ _bounds: b }) =&gt;
    b &amp;&amp; p.x &gt;= b.x &amp;&amp; p.x &lt;= b.x + b.w &amp;&amp; p.y &gt;= b.y &amp;&amp; p.y &lt;= b.y + b.h);
  if (!hit) return;
  drag = { box: hit, dx: p.x - hit.x * canvas.width, dy: p.y - hit.y * canvas.height };
  canvas.setPointerCapture(e.pointerId);
});

canvas.addEventListener("pointermove", (e) =&gt; {
  if (!drag) return;
  const p = toCanvas(e);
  drag.box.x = Math.min(1, Math.max(0, (p.x - drag.dx) / canvas.width));
  drag.box.y = Math.min(1, Math.max(0, (p.y - drag.dy) / canvas.height));
  requestAnimationFrame(render);
});

canvas.addEventListener("pointerup", () =&gt; (drag = null));
</code></pre>
<p><code>setPointerCapture</code> keeps the drag alive when the finger slides off the canvas edge. Combined with <code>touch-action: none</code>, the page doesn't scroll while you're dragging text on a phone.</p>
<p>Re-rendering a full 4000-pixel image on every move can lag on older phones. If it does, render the preview from a downscaled copy of the image and only draw full resolution on export, using the same fractional positions.</p>
<h2>Export: PNG vs JPG</h2>
<pre><code class="language-js">function download(type = "image/png") {
  canvas.toBlob((blob) =&gt; {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `meme.${type === "image/png" ? "png" : "jpg"}`;
    a.click();
    setTimeout(() =&gt; URL.revokeObjectURL(a.href), 1000);
  }, type, 0.9);
}
</code></pre>
<ul>
<li><strong>PNG</strong> keeps text edges perfectly crisp and supports transparency. Files are larger.</li>
<li><strong>JPG</strong> at 0.85–0.9 quality is much smaller and fine for sharing in chat apps, which will recompress anyway. JPG has no alpha channel, so if your image has transparent areas, fill the canvas with white first or they turn black.</li>
</ul>
<h2>Small things users notice</h2>
<ul>
<li><strong>Accessibility:</strong> keep caption inputs as real <code>&lt;input&gt;</code> elements outside the canvas. The canvas is a picture; the form is the interface.</li>
<li><strong>iOS memory:</strong> huge canvases can fail silently on Mobile Safari, so cap the export size.</li>
</ul>
<h2>Wrapping up</h2>
<p>The core of a caption generator is about a hundred lines. The polish is in the edges: EXIF orientation, CORS for templates, round line joins, auto-fit, pointer capture and choosing the right export format. Get those right and the tool feels instant and trustworthy, especially when you can honestly say nothing gets uploaded.</p>
<hr />
<p><em>Abid Ali is a full-stack web developer in Pakistan and the builder of <a href="https://www.memetap.pk/">Meme Tap</a>, a free meme soundboard and browser meme tools.</em></p>
]]></content:encoded></item><item><title><![CDATA[X (Twitter) Card Not Showing? summary vs summary_large_image, Fallbacks, and the Checks That Actually Matter]]></title><description><![CDATA[You post a link on X and get one of three disappointing results: a bare URL with no card, a small square thumbnail when you wanted the big wide image, or a card with an image from three redesigns ago.]]></description><link>https://abidali671.hashnode.dev/x-twitter-card-not-showing-summary-vs-summary-large-image-fallbacks-and-the-checks-that-actually-matter</link><guid isPermaLink="true">https://abidali671.hashnode.dev/x-twitter-card-not-showing-summary-vs-summary-large-image-fallbacks-and-the-checks-that-actually-matter</guid><category><![CDATA[Twitter]]></category><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[HTML]]></category><category><![CDATA[openGraph]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Fri, 16 Oct 2026 07:58:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/4402c9ef-ce2a-4c65-9bf3-4c15c2a876a3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You post a link on X and get one of three disappointing results: a bare URL with no card, a small square thumbnail when you wanted the big wide image, or a card with an image from three redesigns ago.</p>
<p>X's card system has a few quirks that don't apply to Facebook or LinkedIn, and the official Card Validator stopped showing previews back in 2022, so a lot of people are debugging blind. This post covers how X decides what to show, the fallbacks it uses, and a short troubleshooting order that fixes most cases.</p>
<h2>The tag that decides the layout</h2>
<p>X supports a few card types, but for normal web pages only two matter:</p>
<ul>
<li><p><code>summary</code> – compact card, small square image beside the title.</p>
</li>
<li><p><code>summary_large_image</code> – wide image above the title.</p>
</li>
</ul>
<p>The layout comes from one tag:</p>
<pre><code class="language-html">&lt;meta name="twitter:card" content="summary_large_image" /&gt;
</code></pre>
<p>This is the single most common cause of "my image is tiny on X". If <code>twitter:card</code> is missing, you don't get the large layout. Open Graph tags can fill in the title, description, and image, but no Open Graph tag selects the large card. <code>og:type="article"</code> won't do it either.</p>
<p>Note the attribute: Twitter tags were specified with <code>name=</code>, while Open Graph uses <code>property=</code>. X is generally tolerant of either in practice, but <code>name="twitter:..."</code> is the documented form, so use it.</p>
<h2>The full minimal set</h2>
<pre><code class="language-html">&lt;!-- Open Graph: used by Facebook, LinkedIn, WhatsApp, Slack, Discord... and by X as a fallback --&gt;
&lt;meta property="og:title" content="How we cut our build time by 70%" /&gt;
&lt;meta property="og:description" content="Caching, parallel jobs, and one embarrassing config flag." /&gt;
&lt;meta property="og:image" content="https://example.com/og/build-time.jpg" /&gt;
&lt;meta property="og:url" content="https://example.com/blog/build-time" /&gt;

&lt;!-- X-specific --&gt;
&lt;meta name="twitter:card" content="summary_large_image" /&gt;
&lt;meta name="twitter:site" content="@example" /&gt;
</code></pre>
<p>That's enough. X falls back to <code>og:title</code>, <code>og:description</code> and <code>og:image</code> when <code>twitter:title</code>, <code>twitter:description</code> and <code>twitter:image</code> are absent. You only need the Twitter-specific versions when you want different text or a different crop on X than everywhere else.</p>
<h2>How the fallbacks work (and where they bite)</h2>
<p>The fallback chain is roughly:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>X looks for</th>
<th>Then falls back to</th>
</tr>
</thead>
<tbody><tr>
<td>Layout</td>
<td><code>twitter:card</code></td>
<td>default compact behavior</td>
</tr>
<tr>
<td>Title</td>
<td><code>twitter:title</code></td>
<td><code>og:title</code>, then <code>&lt;title&gt;</code></td>
</tr>
<tr>
<td>Description</td>
<td><code>twitter:description</code></td>
<td><code>og:description</code></td>
</tr>
<tr>
<td>Image</td>
<td><code>twitter:image</code></td>
<td><code>og:image</code></td>
</tr>
</tbody></table>
<p>Two things bite people here.</p>
<p><strong>1. Conflicting values.</strong> A CMS plugin writes <code>og:image</code>, your theme hard-codes an old <code>twitter:image</code>. X prefers the Twitter tag, so it shows the old picture while Facebook shows the new one. If you see different images on different platforms, look for a stale <code>twitter:image</code> first:</p>
<pre><code class="language-bash">curl -s https://example.com/blog/build-time | grep -iE 'twitter:|og:image'
</code></pre>
<p><strong>2. The reverse doesn't apply.</strong> LinkedIn and Facebook don't use <code>twitter:image</code> as a fallback for <code>og:image</code>. If you only set Twitter tags, X looks fine and LinkedIn shows nothing. Set Open Graph first, Twitter second.</p>
<h2>Image requirements for the large card</h2>
<p>For <code>summary_large_image</code>, the image needs to be big enough to fill a wide card. Practical guidance:</p>
<ul>
<li><p><strong>Aspect ratio:</strong> about 2:1 or 1.91:1. A 1200×630 image (the common Open Graph size) works well for X too; X will crop slightly toward 2:1, so keep important content away from the top and bottom edges.</p>
</li>
<li><p><strong>Size:</strong> X's docs have long listed a minimum of 300×157 and a maximum file size of 5 MB. Aim for something far smaller in bytes; a compressed JPG or WebP around 1200×630 is usually 100–300 KB.</p>
</li>
<li><p><strong>Format:</strong> JPG, PNG, WebP, or GIF (only the first frame is used). SVG isn't supported.</p>
</li>
<li><p><strong>URL:</strong> absolute <code>https://</code>, publicly reachable, no login, no expiring signature.</p>
</li>
</ul>
<p>For a <code>summary</code> card the image is shown small and square, so a centered logo or icon works; a wide banner will be cropped to its middle.</p>
<h2>Why no card appears at all</h2>
<p>When X shows only a plain link, work through this in order:</p>
<p><strong>1. The tags aren't in the raw HTML.</strong> X's crawler doesn't run JavaScript. If your framework adds meta tags client-side, X never sees them. Check with <code>curl</code>, not with DevTools; DevTools shows the DOM after JavaScript.</p>
<p><strong>2. The crawler is blocked.</strong> X's crawler identifies as <code>Twitterbot</code>. A <code>robots.txt</code> that disallows it, a WAF challenge, or bot protection on your CDN will kill the card. Check robots:</p>
<pre><code class="language-plaintext">User-agent: Twitterbot
Allow: /
</code></pre>
<p>and test the response it gets:</p>
<pre><code class="language-bash">curl -sI -A "Twitterbot/1.0" https://example.com/blog/build-time
</code></pre>
<p>You want a <code>200</code>, not a <code>403</code> or a challenge page.</p>
<p><strong>3. The image fails.</strong> The tags are fine, but the image URL 404s, redirects to HTML, or times out. You'll usually get a text-only card or no card. Request the image URL directly with the same <code>curl -sI</code> command and confirm <code>content-type: image/...</code>.</p>
<p><strong>4. It's cached.</strong> X caches card data per URL. Once a link has been crawled, changes to your tags may not show up immediately. With the old validator's preview gone, the practical options are to wait, or to share a slightly different URL (adding a harmless query parameter like <code>?v=2</code> makes X treat it as a new URL). If you changed the image itself, also change the image's URL, so caches between you and X don't serve the old file.</p>
<h2>A faster way to see what X will read</h2>
<p>Reading raw HTML works, but it's easy to miss which tag is winning when Open Graph and Twitter tags disagree.</p>
<p>I built the <a href="https://www.ogscope.pk/twitter-card-checker">OG Scope Twitter card checker</a> for that. Paste a public URL and it reads <code>twitter:card</code> and the tags around it, draws an estimated X card, and adds notes when it's using a fallback: for example when <code>twitter:card</code> is missing and the summary layout is assumed, or when the image comes from <code>og:image</code> because <code>twitter:image</code> isn't set. It's free and needs no account.</p>
<p>Two honest limits: it's an estimate built from your public HTML, not X's own renderer, and it can't see what X has cached. Player and app cards appear in the metadata table but aren't rendered as players.</p>
<h2>Quick checklist before you post</h2>
<ul>
<li><p><code>twitter:card</code> is set on purpose: <code>summary_large_image</code> if the image matters.</p>
</li>
<li><p><code>og:title</code>, <code>og:description</code>, <code>og:image</code> exist in the raw HTML; Twitter-specific tags only where you want different values.</p>
</li>
<li><p>No stale <code>twitter:image</code> overriding a newer <code>og:image</code>.</p>
</li>
<li><p>Image is absolute HTTPS, loads without login, roughly 1200×630, well under 5 MB.</p>
</li>
<li><p><code>Twitterbot</code> gets a <code>200</code> from both the page and the image.</p>
</li>
<li><p>If the link was shared before, you've changed the URL or waited out the cache.</p>
</li>
</ul>
<p>Most "X card not showing" problems are one missing tag or one blocked request. Find which, fix it once, and every future post gets the card you designed.</p>
<hr />
<p><em>Abid Ali builds small web tools from Pakistan. OG Scope, his free link preview checker, lives at</em> <a href="https://www.ogscope.pk/"><em>ogscope.pk</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Open Graph Tags in a React + Vite SPA: Why Crawlers See Your App Shell, and Three Ways to Fix It]]></title><description><![CDATA[If you've built a single-page app with React and Vite, you've probably seen this: every link you share, whether it's /pricing, /blog/my-post or /u/someone, shows the same card. Usually it's the title ]]></description><link>https://abidali671.hashnode.dev/open-graph-tags-in-a-react-vite-spa-why-crawlers-see-your-app-shell-and-three-ways-to-fix-it</link><guid isPermaLink="true">https://abidali671.hashnode.dev/open-graph-tags-in-a-react-vite-spa-why-crawlers-see-your-app-shell-and-three-ways-to-fix-it</guid><category><![CDATA[React]]></category><category><![CDATA[vite]]></category><category><![CDATA[SEO]]></category><category><![CDATA[openGraph]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Fri, 16 Oct 2026 07:57:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/e3325568-f383-4fea-bff7-6cb5509f2b07.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you've built a single-page app with React and Vite, you've probably seen this: every link you share, whether it's <code>/pricing</code>, <code>/blog/my-post</code> or <code>/u/someone</code>, shows the same card. Usually it's the title from <code>index.html</code> ("Vite + React", if nobody changed it), the default favicon, and no image.</p>
<p>You added <code>react-helmet-async</code> or set <code>document.title</code> per route. It works in the browser tab. It does nothing for Slack, LinkedIn, WhatsApp or Facebook. Here's why, and three practical ways out, from cheapest to most thorough.</p>
<h2>Why the per-route tags never reach the crawler</h2>
<p>When a social platform builds a link preview, its crawler requests your URL and reads the HTML it gets back. That's it. For a Vite SPA, every route returns the same file:</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;title&gt;Vite + React&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id="root"&gt;&lt;/div&gt;
    &lt;script type="module" src="/assets/index-a1b2c3.js"&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Your route-specific tags are created later, by JavaScript, inside the browser. Most preview crawlers don't execute JavaScript at all. Some search engines do render JS, which is why SEO tutorials sometimes say client-side meta tags are "fine". For link previews, they aren't.</p>
<p>You can confirm this in five seconds:</p>
<pre><code class="language-bash">curl -s https://yourapp.com/blog/my-post | grep -iE '&lt;title&gt;|og:'
</code></pre>
<p>If you only see the <code>index.html</code> title, crawlers are seeing the same thing.</p>
<h2>Option 1: Good static defaults (10 minutes)</h2>
<p>If most of your shared links go to the homepage, or the app is behind a login anyway, you may not need per-route previews at all. Just make the shell's card good:</p>
<pre><code class="language-html">&lt;!-- index.html --&gt;
&lt;title&gt;Acme – Invoices that chase themselves&lt;/title&gt;
&lt;meta name="description" content="Send invoices, get paid, and stop writing reminder emails." /&gt;
&lt;meta property="og:title" content="Acme – Invoices that chase themselves" /&gt;
&lt;meta property="og:description" content="Send invoices, get paid, and stop writing reminder emails." /&gt;
&lt;meta property="og:image" content="https://acme.com/og/default.jpg" /&gt;
&lt;meta property="og:url" content="https://acme.com/" /&gt;
&lt;meta property="og:type" content="website" /&gt;
&lt;meta name="twitter:card" content="summary_large_image" /&gt;
</code></pre>
<p>Two rules: the image URL must be absolute, and the file should be about 1200×630. Every route now shares a decent generic card instead of "Vite + React". This is honestly enough for many dashboards and internal tools.</p>
<h2>Option 2: Prerender the routes that get shared (an afternoon)</h2>
<p>For marketing pages and blog posts whose content is known at build time, generate a static HTML file per route with the right tags baked in. The SPA still hydrates normally for users.</p>
<p>A minimal approach without adopting a whole framework: after <code>vite build</code>, write one HTML file per route by injecting tags into the built <code>index.html</code>.</p>
<pre><code class="language-js">// scripts/prerender-meta.mjs
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";

const SITE = "https://acme.com";
const routes = [
  { path: "/pricing", title: "Pricing – Acme", description: "Simple plans, no per-seat surprises.", image: "/og/pricing.jpg" },
  { path: "/blog/late-payments", title: "Why clients pay late (and what fixes it)", description: "Data from 4,000 invoices.", image: "/og/late-payments.jpg" },
];

const esc = (s) =&gt; s.replace(/&amp;/g, "&amp;amp;").replace(/"/g, "&amp;quot;").replace(/&lt;/g, "&amp;lt;");
const shell = await readFile("dist/index.html", "utf8");

for (const r of routes) {
  const tags = `
    &lt;title&gt;${esc(r.title)}&lt;/title&gt;
    &lt;meta name="description" content="${esc(r.description)}" /&gt;
    &lt;meta property="og:title" content="${esc(r.title)}" /&gt;
    &lt;meta property="og:description" content="${esc(r.description)}" /&gt;
    &lt;meta property="og:image" content="${SITE}${r.image}" /&gt;
    &lt;meta property="og:url" content="${SITE}${r.path}" /&gt;
    &lt;meta property="og:type" content="article" /&gt;
    &lt;meta name="twitter:card" content="summary_large_image" /&gt;`;

  const html = shell
    .replace(/&lt;title&gt;.*?&lt;\/title&gt;/s, "")
    .replace(/&lt;meta (property="og:|name="twitter:|name="description")[^&gt;]*&gt;\s*/g, "")
    .replace("&lt;/head&gt;", `${tags}\n  &lt;/head&gt;`);

  const out = `dist${r.path}/index.html`;
  await mkdir(dirname(out), { recursive: true });
  await writeFile(out, html);
}
</code></pre>
<p>Add it to your build: <code>"build": "vite build &amp;&amp; node scripts/prerender-meta.mjs"</code>. Static hosts (Netlify, Cloudflare Pages, Vercel, S3 behind a CDN) will serve <code>dist/pricing/index.html</code> for <code>/pricing</code> before falling back to your SPA catch-all.</p>
<p>Notice the script strips the shell's default tags before injecting new ones. Skip that and you'll ship two <code>og:title</code> tags, and crawlers will pick one, often the wrong one.</p>
<p>If you want real prerendered content (not just tags), Vike (formerly <code>vite-plugin-ssr</code>), or moving content pages to Astro or Next.js, are the longer-term answer.</p>
<h2>Option 3: Inject tags at the edge for dynamic routes</h2>
<p>Prerendering can't help with <code>/u/:username</code> or <code>/p/:id</code> when you have thousands of records created after the build. For those, put a tiny function in front of your static files that rewrites the <code>&lt;head&gt;</code> on the fly.</p>
<p>Here's the idea with a Cloudflare Worker using <code>HTMLRewriter</code> (Netlify Edge Functions and Vercel Middleware can do the same thing):</p>
<pre><code class="language-js">export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const res = await env.ASSETS.fetch(request);
    const match = url.pathname.match(/^\/p\/([\w-]+)$/);
    if (!match) return res;

    const post = await fetch(`https://api.acme.com/posts/${match[1]}`).then((r) =&gt; r.ok ? r.json() : null);
    if (!post) return res;

    const set = (prop, value) =&gt; ({
      element(el) { el.setAttribute("content", value); },
    });

    return new HTMLRewriter()
      .on('meta[property="og:title"]', set("og:title", post.title))
      .on('meta[property="og:description"]', set("og:description", post.summary))
      .on('meta[property="og:image"]', set("og:image", post.imageUrl))
      .on('meta[property="og:url"]', set("og:url", url.toString()))
      .transform(res);
  },
};
</code></pre>
<p>This works because Option 1 already put default tags in the shell; the edge function just swaps the values. Users and crawlers get the same HTML, so there's no user-agent sniffing and no cloaking concerns.</p>
<p>Keep the API call fast and cached. A preview crawler won't wait long, and a slow response can mean no card at all.</p>
<h2>Checking the result</h2>
<p>After deploying, verify what a crawler actually receives, route by route. <code>curl</code> works, but reading raw HTML for every page gets tedious and doesn't show you the card.</p>
<p>I check these with <a href="https://www.ogscope.pk/open-graph-preview">OG Scope's Open Graph preview</a>, a free tool I built. It fetches the public HTML on the server without running JavaScript (which is exactly the crawler's view of an SPA), lists the Open Graph and Twitter tags it found, flags missing or relative images, and draws estimated cards for platforms like LinkedIn, WhatsApp, Facebook, X and Slack. If it shows "Vite + React", so will the crawlers.</p>
<p>One caveat for any checker: platforms cache previews. If you fixed the tags but a link shared yesterday still shows the old card, ask the platform to re-scrape (Facebook's Sharing Debugger, LinkedIn's Post Inspector) or change the image URL.</p>
<h2>Which option should you pick?</h2>
<ul>
<li><p><strong>App behind login, few public pages:</strong> Option 1.</p>
</li>
<li><p><strong>Marketing site or blog inside the SPA:</strong> Option 1 + Option 2.</p>
</li>
<li><p><strong>User-generated public pages:</strong> Option 1 + Option 3.</p>
</li>
<li><p><strong>Content is the product:</strong> consider moving those pages to an SSR or static framework and keeping the SPA for the app itself.</p>
</li>
</ul>
<p>Whatever you choose, the rule doesn't change: if the tag isn't in the first HTML response, it doesn't exist for a link preview.</p>
<hr />
<p><em>Abid Ali is a developer in Pakistan who builds small web tools, including OG Scope, a free Open Graph checker.</em></p>
]]></content:encoded></item><item><title><![CDATA[How I Price a Website Project as a Freelance Developer: A Small TypeScript Quote Calculator (Fees Included)]]></title><description><![CDATA[A lot of first website quotes on freelance platforms go the same way. You pick a number that "feels fair", subtract nothing, and promise five pages in a week. Then the platform fee comes out, the clie]]></description><link>https://abidali671.hashnode.dev/price-website-project-freelance-developer-quote-calculator</link><guid isPermaLink="true">https://abidali671.hashnode.dev/price-website-project-freelance-developer-quote-calculator</guid><category><![CDATA[Freelancing]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Career]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Thu, 15 Oct 2026 09:39:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/27ec1cde-9ce2-4975-8eae-f5dd13e9970b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A lot of first website quotes on freelance platforms go the same way. You pick a number that "feels fair", subtract nothing, and promise five pages in a week. Then the platform fee comes out, the client asks for two extra revision rounds, and the contact form turns out to need a backend. By the end you have worked for about half of what you thought you were charging.</p>
<p>The fix was not a better gut feeling. It was a tiny bit of arithmetic I now run before every quote. It fits in one TypeScript file, and writing it down forced me to make decisions I used to skip: how many days the work really takes, what I want to keep after fees, and what is not included.</p>
<p>This post walks through that calculator and the reasoning behind each line. The approach comes from the Digital Dialogue guide on <a href="https://www.digitaldialogue.pk/blogs/freelancing/how-much-to-charge-for-a-website-as-a-freelancer">how much to charge for a website as a freelancer</a>: price by what you build and how long it takes, then add the fee back in. The code just makes it hard to cheat.</p>
<h2>The three numbers you actually need</h2>
<ol>
<li><strong>Days.</strong> Not hours you hope for. Days the last similar project took, including revisions and deployment. If you have never tracked this, time a demo build this week. A "weekend" five-page site has a way of turning into three weeks.</li>
<li><strong>Net day rate.</strong> What you want to keep for one working day, after the platform fee and before tax.</li>
<li><strong>Fee percent.</strong> The current freelancer service fee shown in your own account. Platforms change it, so do not copy a number from a blog post (including this one).</li>
</ol>
<p>Everything else is scope.</p>
<h2>The most common mistake: subtracting the fee instead of grossing up</h2>
<p>If you need $700 and the fee is 10%, the client price is not $700 + 10% = $770. The fee is taken from what the client pays, so you need:</p>
<pre><code>client price = net / (1 - fee)
             = 700 / 0.9
             ≈ 778
</code></pre>
<p>$770 leaves you $693. It is a small gap on one job and a real gap over a year. The Digital Dialogue breakdown of <a href="https://www.digitaldialogue.pk/blogs/freelancing/upwork-fees-how-much-do-freelancers-earn">Upwork fees and how much freelancers actually earn</a> walks through the same math for $100, $500 and $1,000 jobs, plus withdrawal costs, which are smaller but still part of what you keep.</p>
<h2>The calculator</h2>
<pre><code class="language-ts">// quote.ts
type ProjectKind = "landing" | "business" | "app-slice";

interface QuoteInput {
  kind: ProjectKind;
  days: number;            // from your own history, not optimism
  netDayRate: number;      // what you keep per day, after fees
  feePercent: number;      // from your account today, e.g. 10
  withdrawalFee?: number;  // flat cost per payout, if any
  revisionRounds: number;
  extras?: { label: string; days: number }[]; // copywriting, rush, shop...
}

interface Quote {
  totalDays: number;
  netTarget: number;
  clientPrice: number;
  feeAmount: number;
  youKeep: number;
  floorCheck: string;
}

// Your personal floors: the lowest CLIENT price you accept per product type.
const FLOORS: Record&lt;ProjectKind, number&gt; = {
  landing: 300,
  business: 900,
  "app-slice": 1500,
};

function roundToSayable(n: number): number {
  // Round up to a number you can say out loud without sounding random.
  if (n &lt; 1000) return Math.ceil(n / 25) * 25;
  return Math.ceil(n / 50) * 50;
}

export function quote(input: QuoteInput): Quote {
  const extraDays = (input.extras ?? []).reduce((s, e) =&gt; s + e.days, 0);
  const totalDays = input.days + extraDays;

  const netTarget = totalDays * input.netDayRate + (input.withdrawalFee ?? 0);
  const fee = input.feePercent / 100;

  const clientPrice = roundToSayable(netTarget / (1 - fee));
  const feeAmount = +(clientPrice * fee).toFixed(2);
  const youKeep = +(clientPrice - feeAmount - (input.withdrawalFee ?? 0)).toFixed(2);

  const floor = FLOORS[input.kind];
  const floorCheck =
    clientPrice &gt;= floor
      ? "OK: above your floor"
      : `Below your ${input.kind} floor of ${floor}. Shrink the scope or raise the price.`;

  return { totalDays, netTarget, clientPrice, feeAmount, youKeep, floorCheck };
}
</code></pre>
<p>Run it with your own numbers:</p>
<pre><code class="language-ts">import { quote } from "./quote";

console.table(
  quote({
    kind: "business",
    days: 8,
    netDayRate: 90,
    feePercent: 10,
    withdrawalFee: 3,
    revisionRounds: 2,
    extras: [{ label: "Client wants a blog section", days: 2 }],
  })
);
</code></pre>
<p>That prints a client price of 1,050 for 10 days, about 942 kept after the fee and payout cost, and a floor check. The exact figures do not matter. What matters is that every input is a decision you made on purpose.</p>
<h2>Why the code is split by project type</h2>
<p>A landing page, a small business site and a small web app are different products, and the calculator refuses to treat them as one sliding scale.</p>
<ul>
<li><strong>Landing page:</strong> one screen, one action (a form, a booking link, a checkout). Often one to three days. Price the days, not the fact that it is "only one page".</li>
<li><strong>Business site:</strong> home, about, services, contact, maybe a gallery. Shared header and footer, content that arrives late, two revision rounds. Often one to two weeks if you are not also designing the brand.</li>
<li><strong>App slice:</strong> anything with accounts or saved data. Do not quote the whole app. Quote the first slice (sign in, one main screen, one editable record type) and make later slices new milestones.</li>
</ul>
<p>The <code>FLOORS</code> map is the most useful line in the file. When a job comes in under your floor, the calculator does not offer a discount. It tells you to cut scope until the number fits. If a client's budget matches your landing page floor, sell them a landing page. Do not squeeze five pages into it and call it a package.</p>
<h2>Turning the number into a proposal</h2>
<p>A price with no page list is a discount waiting to happen. I extend the script to print a scope block I can paste straight into the proposal:</p>
<pre><code class="language-ts">export function scopeBlock(kind: ProjectKind, q: Quote, rounds: number, pages: string[]) {
  return [
    `Scope: ${pages.join(", ")}`,
    `Includes: responsive layout, contact form, deployment to your host, ${rounds} revision round(s)`,
    `Not included: logo design, copywriting, online shop, ongoing edits`,
    `Timeline: about ${Math.ceil(q.totalDays / 5)} week(s)`,
    `Price: ${q.clientPrice} (fixed, for the scope above)`,
    `Extra pages or features become a separate milestone.`,
  ].join("\n");
}
</code></pre>
<p>The "not included" line does as much work as the price. Silence reads as yes. The full structure I follow around that block (two lines about their site, the scope, price and timeline, one relevant link, one or two questions) is in the Digital Dialogue <a href="https://www.digitaldialogue.pk/blogs/freelancing/upwork-proposal-template-for-a-website-project">Upwork proposal template for a website project</a>.</p>
<h2>Things the calculator will not fix for you</h2>
<ul>
<li><strong>Bad day estimates.</strong> Garbage in, polite-looking garbage out. Keep a simple log of how long each project actually took and update your defaults every few jobs.</li>
<li><strong>Mid-project price changes.</strong> Raise the price when the client adds copywriting, a shop or a rush date, before you accept the offer. Raising it halfway through because you feel busy damages trust.</li>
<li><strong>Moving off-platform to dodge the fee.</strong> It breaks the platform's rules, and you lose payment protection and the contract record. If you do not want the fee, find clients somewhere else from the start.</li>
<li><strong>Hourly work.</strong> The same gross-up applies: if you want $40 an hour after a 10% fee, the profile rate is about $45. Publish the higher number.</li>
</ul>
<h2>The short version</h2>
<ol>
<li>Count days honestly.</li>
<li>Decide your net day rate.</li>
<li>Gross up by the fee: <code>net / (1 - fee)</code>.</li>
<li>Round to a number you can say out loud.</li>
<li>Check it against your floor for that product type.</li>
<li>Attach it to a written scope with a "not included" line.</li>
</ol>
<p>It takes two minutes per quote, and it means you stop doing the math after the project is already finished. If you want more practical guides like this on pricing, proposals and platform fees, Digital Dialogue has a whole <a href="https://www.digitaldialogue.pk/tags/freelance-rates">freelance rates section</a> written for developers who are just starting out.</p>
]]></content:encoded></item><item><title><![CDATA[I Track My Upwork Proposals Like a Sales Pipeline: A Tiny TypeScript System for Freelance Developers]]></title><description><![CDATA[Most developers who start freelancing on Upwork treat proposals like pull requests to a repo they will never see again. You write one, submit it, forget about it, and hope. A few weeks later you have ]]></description><link>https://abidali671.hashnode.dev/i-track-my-upwork-proposals-like-a-sales-pipeline-a-tiny-typescript-system-for-freelance-developers</link><guid isPermaLink="true">https://abidali671.hashnode.dev/i-track-my-upwork-proposals-like-a-sales-pipeline-a-tiny-typescript-system-for-freelance-developers</guid><category><![CDATA[Freelancing]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[Productivity]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Tue, 13 Oct 2026 18:13:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/23bbb679-1939-47af-9dd8-ea850685f273.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most developers who start freelancing on Upwork treat proposals like pull requests to a repo they will never see again. You write one, submit it, forget about it, and hope. A few weeks later you have no idea how many you sent, what you spent, which kinds of jobs replied or which clients you promised to follow up with.</p>
<p>As a full stack developer from Karachi, I went through exactly that phase. What fixed it was not a fancy CRM. It was treating proposals as data, the same way I would treat any other system I wanted to improve. This post shows the small TypeScript setup I use: a typed log, a CLI to add and update entries, and a report that answers three questions every week.</p>
<h2>The three questions</h2>
<p>Before writing code, decide what you want to learn. Mine are:</p>
<ol>
<li><strong>Reply rate by job type.</strong> Which kinds of jobs actually answer me?</li>
<li><strong>Cost per reply.</strong> How many Connects do I spend to get one conversation?</li>
<li><strong>Follow-ups due.</strong> Which clients am I supposed to message today?</li>
</ol>
<p>Everything in the system exists to answer those three. If a field does not help, it does not go in.</p>
<h2>The data model</h2>
<p>A single JSON file in a private repo is enough. It is diffable, greppable and survives laptop changes.</p>
<pre><code class="language-ts">// src/types.ts
export type Status =
  | "sent"
  | "viewed"
  | "replied"
  | "interview"
  | "hired"
  | "declined"
  | "no_response";

export type JobType = "landing-page" | "nextjs-app" | "bugfix" | "wordpress" | "api" | "other";

export interface Proposal {
  id: string;            // short slug, e.g. "2026-10-clinic-site"
  jobTitle: string;
  jobUrl?: string;
  jobType: JobType;
  clientCountry?: string;
  paymentVerified: boolean;
  proposalsOnJob?: string; // "5-10", "20-50", as shown on the job
  budget?: number;        // fixed price or estimated total, USD
  connects: number;
  sentAt: string;         // ISO date
  status: Status;
  followUpOn?: string;    // ISO date
  notes?: string;
}
</code></pre>
<p><code>paymentVerified</code> and <code>proposalsOnJob</code> are there because they are the two signals I check before applying. Logging them lets me test whether my instincts are right: do verified clients with few proposals really reply more often? After a couple of months, you will know.</p>
<h2>Loading and saving safely</h2>
<p>Hand-editing JSON is fine until you typo a status. A small validator keeps the file honest.</p>
<pre><code class="language-ts">// src/store.ts
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import type { Proposal, Status } from "./types";

const FILE = new URL("../data/proposals.json", import.meta.url);
const STATUSES: Status[] = [
  "sent", "viewed", "replied", "interview", "hired", "declined", "no_response",
];

export function load(): Proposal[] {
  if (!existsSync(FILE)) return [];
  const data = JSON.parse(readFileSync(FILE, "utf8")) as Proposal[];
  for (const p of data) {
    if (!STATUSES.includes(p.status)) {
      throw new Error(`Bad status "${p.status}" on ${p.id}`);
    }
    if (Number.isNaN(Date.parse(p.sentAt))) {
      throw new Error(`Bad sentAt on ${p.id}`);
    }
  }
  return data;
}

export function save(all: Proposal[]) {
  const sorted = [...all].sort((a, b) =&gt; b.sentAt.localeCompare(a.sentAt));
  writeFileSync(FILE, JSON.stringify(sorted, null, 2) + "\n");
}
</code></pre>
<p>If you prefer stronger guarantees, swap the manual checks for a <code>zod</code> schema. For a single-user file, this is plenty.</p>
<h2>A CLI that takes ten seconds to use</h2>
<p>The system only works if logging a proposal is faster than skipping it. So the CLI does two things: add and update.</p>
<pre><code class="language-ts">// src/cli.ts
import { load, save } from "./store";
import type { Proposal, Status, JobType } from "./types";

const [cmd, ...args] = process.argv.slice(2);
const all = load();
const today = new Date().toISOString().slice(0, 10);

function addDays(iso: string, n: number) {
  const d = new Date(iso);
  d.setDate(d.getDate() + n);
  return d.toISOString().slice(0, 10);
}

if (cmd === "add") {
  // add &lt;id&gt; &lt;jobType&gt; &lt;connects&gt; &lt;verified:y|n&gt; "&lt;title&gt;"
  const [id, jobType, connects, verified, ...title] = args;
  const p: Proposal = {
    id,
    jobType: jobType as JobType,
    connects: Number(connects),
    paymentVerified: verified === "y",
    jobTitle: title.join(" "),
    sentAt: today,
    status: "sent",
    followUpOn: addDays(today, 3),
  };
  save([p, ...all]);
  console.log(`Logged ${id}, follow-up on ${p.followUpOn}`);
} else if (cmd === "set") {
  // set &lt;id&gt; &lt;status&gt; [followUpInDays]
  const [id, status, days] = args;
  const p = all.find((x) =&gt; x.id === id);
  if (!p) throw new Error(`No proposal ${id}`);
  p.status = status as Status;
  p.followUpOn = days ? addDays(today, Number(days)) : undefined;
  save(all);
  console.log(`${id} -&gt; ${status}`);
} else {
  console.log("Usage: add | set");
}
</code></pre>
<p>Add a script to <code>package.json</code> (<code>"p": "tsx src/cli.ts"</code>) and logging becomes:</p>
<pre><code class="language-bash">npm run p -- add 2026-10-clinic nextjs-app 16 y "Rebuild clinic site in Next.js"
npm run p -- set 2026-10-clinic replied 2
</code></pre>
<p>The default three-day follow-up is deliberate. On Upwork you generally cannot message a client who has not replied, so for most proposals the "follow-up" is really a reminder to check status and mark it <code>no_response</code> after a week. For clients who did reply, it is a reminder to actually move the conversation forward.</p>
<h2>The weekly report</h2>
<p>This is where the log pays for itself.</p>
<pre><code class="language-ts">// src/report.ts
import { load } from "./store";

const all = load();
const today = new Date().toISOString().slice(0, 10);
const replied = (s: string) =&gt; ["replied", "interview", "hired"].includes(s);

const byType = new Map&lt;string, { sent: number; replies: number; connects: number }&gt;();
for (const p of all) {
  const row = byType.get(p.jobType) ?? { sent: 0, replies: 0, connects: 0 };
  row.sent++;
  row.connects += p.connects;
  if (replied(p.status)) row.replies++;
  byType.set(p.jobType, row);
}

console.table(
  [...byType].map(([type, r]) =&gt; ({
    type,
    sent: r.sent,
    replyRate: `${Math.round((r.replies / r.sent) * 100)}%`,
    connectsPerReply: r.replies ? Math.round(r.connects / r.replies) : "—",
  }))
);

const verified = all.filter((p) =&gt; p.paymentVerified);
const unverified = all.filter((p) =&gt; !p.paymentVerified);
const rate = (xs: typeof all) =&gt;
  xs.length ? `${Math.round((xs.filter((p) =&gt; replied(p.status)).length / xs.length) * 100)}%` : "—";
console.log(`Reply rate, verified: ${rate(verified)} | unverified: ${rate(unverified)}`);

const due = all.filter((p) =&gt; p.followUpOn &amp;&amp; p.followUpOn &lt;= today);
console.log("\nFollow-ups due:");
for (const p of due) console.log(`- ${p.id} (${p.status}): ${p.jobTitle}`);
</code></pre>
<p>Run it every Monday. Within a month the table usually tells you something uncomfortable and useful. A common pattern: small bug-fix jobs reply often but rarely turn into long contracts, while full Next.js app builds reply less but are worth far more. Seeing that in your own numbers changes where you spend Connects. If you want a framework for that decision, Digital Dialogue has a guide on <a href="https://www.digitaldialogue.pk/blogs/freelancing/upwork-connects-when-a-job-is-worth-the-cost">deciding when an Upwork job is worth the Connects</a> that maps well onto these numbers.</p>
<h2>Using the data to choose better jobs</h2>
<p>Numbers do not write proposals for you, but they sharpen your filter. A few rules that tend to fall out of these reports:</p>
<ul>
<li>Skip unverified clients unless the job is a perfect fit. If your log shows their reply rate is far below verified clients, trust it.</li>
<li>Prefer jobs with fewer existing proposals, even if the budget is a bit lower.</li>
<li>Double down on the job types with the best connects-per-reply, and rewrite the proposal template for the worst ones before abandoning them.</li>
</ul>
<p>For the qualitative side, which red flags to look for in a posting, the Digital Dialogue article on <a href="https://www.digitaldialogue.pk/blogs/freelancing/how-to-choose-upwork-jobs-as-a-web-developer">how to choose Upwork jobs as a web developer</a> pairs nicely with the tracker. It also helps to know exactly what the platform takes from each contract; the breakdown of <a href="https://www.digitaldialogue.pk/blogs/freelancing/how-much-does-upwork-cost-fees-for-freelancers-and-clients">Upwork fees for freelancers and clients</a> is worth adding to your <code>budget</code> math so you track take-home rather than headline numbers.</p>
<h2>After the hire: keep the record going</h2>
<p>When a proposal becomes <code>hired</code>, I keep it in the log and add a <code>notes</code> line with the delivery date and a reminder about a month later. Repeat clients are the cheapest work you will ever win, and the tracker is the thing that reminds you to check in. The guide on <a href="https://www.digitaldialogue.pk/blogs/freelancing/customer-follow-up-get-the-next-job-from-the-same-upwork-client">following up to get the next job from the same Upwork client</a> covers what to actually say in that message.</p>
<h2>Wrapping up</h2>
<p>This whole system is three files, no database and no subscription. It turns "I think proposals aren't working" into "my reply rate on WordPress jobs is low and costs me a lot of Connects per conversation, so I'll stop applying to them." That is a decision you can act on.</p>
<p>If you are a developer just starting out, especially in Pakistan where many of us learn Upwork and Fiverr by trial and error, more practical guides like these live in the <a href="https://www.digitaldialogue.pk/blogs/freelancing">Digital Dialogue freelancing section</a>. Build the tracker this week, log every proposal for a month, and let the data argue with your instincts.</p>
]]></content:encoded></item><item><title><![CDATA[Building an Accessible, Keyboard-Driven Soundboard in React]]></title><description><![CDATA[A soundboard looks like the simplest UI on the web: a grid of buttons that play sounds. Then someone tries to use yours with only a keyboard, or with a screen reader, and you discover that a grid of 2]]></description><link>https://abidali671.hashnode.dev/building-an-accessible-keyboard-driven-soundboard-in-react</link><guid isPermaLink="true">https://abidali671.hashnode.dev/building-an-accessible-keyboard-driven-soundboard-in-react</guid><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Accessibility]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Tue, 13 Oct 2026 18:13:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/b554d023-a414-4564-b599-0dc7d95ff4a9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A soundboard looks like the simplest UI on the web: a grid of buttons that play sounds. Then someone tries to use yours with only a keyboard, or with a screen reader, and you discover that a grid of 200 clickable <code>div</code>s is a terrible experience.</p>
<p>I run <a href="https://www.memetap.pk/">Meme Tap, a free browser meme soundboard</a>, and soundboards in general get a lot of keyboard use: streamers with a hand on a game controller, people on Chromebooks in class, folks who just like hotkeys. This post walks through the patterns I would use to build a soundboard grid that is fast with a keyboard and usable with assistive tech. The code is React + TypeScript and framework-agnostic enough to drop into Next.js.</p>
<h2>1. Start with real buttons</h2>
<p>Every sound tile is a <code>&lt;button&gt;</code>. Not a <code>div</code> with <code>onClick</code>, not an <code>a</code> without an <code>href</code>. Buttons give you focus, Enter and Space activation, and a correct role for free.</p>
<pre><code class="language-tsx">type Sound = { id: string; title: string; src: string };

function SoundTile({
  sound,
  playing,
  tabIndex,
  onPlay,
  onFocus,
  refCallback,
}: {
  sound: Sound;
  playing: boolean;
  tabIndex: 0 | -1;
  onPlay: (s: Sound) =&gt; void;
  onFocus: () =&gt; void;
  refCallback: (el: HTMLButtonElement | null) =&gt; void;
}) {
  return (
    &lt;button
      ref={refCallback}
      type="button"
      className="tile"
      tabIndex={tabIndex}
      aria-pressed={playing}
      onClick={() =&gt; onPlay(sound)}
      onFocus={onFocus}
    &gt;
      &lt;span className="tile-title"&gt;{sound.title}&lt;/span&gt;
    &lt;/button&gt;
  );
}
</code></pre>
<p><code>aria-pressed</code> tells screen readers that the button is a toggle and whether it is currently playing. The visible title doubles as the accessible name, so there is no need for an extra <code>aria-label</code> unless the tile is icon-only.</p>
<h2>2. Roving tabindex for the grid</h2>
<p>With 100+ tiles, tabbing through every one is miserable. The standard fix is a <strong>roving tabindex</strong>: only one tile in the grid has <code>tabIndex={0}</code>, every other tile has <code>-1</code>. Tab moves into and out of the grid in one step, and arrow keys move inside it.</p>
<pre><code class="language-tsx">import { useRef, useState, useCallback, KeyboardEvent } from "react";

function useRovingGrid(count: number, columns: number) {
  const [active, setActive] = useState(0);
  const refs = useRef&lt;(HTMLButtonElement | null)[]&gt;([]);

  const focusAt = useCallback(
    (i: number) =&gt; {
      const next = Math.max(0, Math.min(count - 1, i));
      setActive(next);
      refs.current[next]?.focus();
    },
    [count]
  );

  const onKeyDown = (e: KeyboardEvent&lt;HTMLDivElement&gt;) =&gt; {
    const map: Record&lt;string, number&gt; = {
      ArrowRight: active + 1,
      ArrowLeft: active - 1,
      ArrowDown: active + columns,
      ArrowUp: active - columns,
      Home: 0,
      End: count - 1,
    };
    if (e.key in map) {
      e.preventDefault();
      focusAt(map[e.key]);
    }
  };

  return { active, setActive, refs, onKeyDown };
}
</code></pre>
<p>The column count should match the CSS layout. If your grid is responsive, read it from the DOM instead of hardcoding it:</p>
<pre><code class="language-ts">function getColumns(grid: HTMLElement | null): number {
  if (!grid) return 1;
  const cols = getComputedStyle(grid).gridTemplateColumns.split(" ");
  return Math.max(1, cols.length);
}
</code></pre>
<p>Call it in a <code>ResizeObserver</code> callback and store the value in state so arrow navigation follows the layout as it changes.</p>
<h2>3. One audio element, not one per tile</h2>
<p>Creating a <code>new Audio()</code> per tile is tempting and wasteful. On mobile it can also trip autoplay rules in odd ways. A single shared player is simpler and makes "now playing" state trivial.</p>
<pre><code class="language-tsx">import { createContext, useContext, useRef, useState } from "react";

type PlayerCtx = {
  playingId: string | null;
  play: (s: Sound) =&gt; void;
  stop: () =&gt; void;
};

const Player = createContext&lt;PlayerCtx | null&gt;(null);

export function PlayerProvider({ children }: { children: React.ReactNode }) {
  const audioRef = useRef&lt;HTMLAudioElement | null&gt;(null);
  const [playingId, setPlayingId] = useState&lt;string | null&gt;(null);
  const [announce, setAnnounce] = useState("");

  const stop = () =&gt; {
    audioRef.current?.pause();
    setPlayingId(null);
  };

  const play = (s: Sound) =&gt; {
    const audio = (audioRef.current ??= new Audio());
    if (playingId === s.id) return stop(); // second press toggles off
    audio.src = s.src;
    audio.currentTime = 0;
    audio.onended = () =&gt; setPlayingId(null);
    audio
      .play()
      .then(() =&gt; {
        setPlayingId(s.id);
        setAnnounce(`Playing ${s.title}`);
      })
      .catch(() =&gt; setAnnounce(`Could not play ${s.title}`));
  };

  return (
    &lt;Player.Provider value={{ playingId, play, stop }}&gt;
      {children}
      &lt;div aria-live="polite" className="sr-only"&gt;
        {announce}
      &lt;/div&gt;
    &lt;/Player.Provider&gt;
  );
}

export const usePlayer = () =&gt; useContext(Player)!;
</code></pre>
<p>The hidden <code>aria-live="polite"</code> region is the key accessibility piece here. Sighted users see a tile light up; screen-reader users hear "Playing Bruh" without losing their place. Use <code>polite</code>, not <code>assertive</code>, so announcements do not cut off whatever the screen reader was saying.</p>
<h2>4. Global hotkeys that don't fight assistive tech</h2>
<p>Power users want shortcuts: Escape to stop, <code>/</code> to jump to search, <code>R</code> for a random sound. The trick is not stealing keys when the user is typing or when a modifier is held.</p>
<pre><code class="language-ts">import { useEffect } from "react";

export function useHotkeys(handlers: Record&lt;string, () =&gt; void&gt;) {
  useEffect(() =&gt; {
    const onKey = (e: globalThis.KeyboardEvent) =&gt; {
      if (e.ctrlKey || e.metaKey || e.altKey) return;
      const t = e.target as HTMLElement;
      const typing =
        t.isContentEditable ||
        ["INPUT", "TEXTAREA", "SELECT"].includes(t.tagName);
      if (typing &amp;&amp; e.key !== "Escape") return;

      const fn = handlers[e.key.toLowerCase()];
      if (fn) {
        e.preventDefault();
        fn();
      }
    };
    window.addEventListener("keydown", onKey);
    return () =&gt; window.removeEventListener("keydown", onKey);
  }, [handlers]);
}
</code></pre>
<p>Memoise the <code>handlers</code> object with <code>useMemo</code> so the listener is not re-attached on every render.</p>
<p>Single-character shortcuts are a known accessibility issue (WCAG 2.1.4): speech-input users can trigger them by accident. Offer a setting to turn them off, and document them in a visible "Keyboard shortcuts" dialog opened with <code>?</code>.</p>
<h2>5. Putting the grid together</h2>
<pre><code class="language-tsx">function SoundGrid({ sounds }: { sounds: Sound[] }) {
  const gridRef = useRef&lt;HTMLDivElement&gt;(null);
  const [columns, setColumns] = useState(4);
  const { active, setActive, refs, onKeyDown } = useRovingGrid(
    sounds.length,
    columns
  );
  const { playingId, play, stop } = usePlayer();

  useEffect(() =&gt; {
    const ro = new ResizeObserver(() =&gt; setColumns(getColumns(gridRef.current)));
    if (gridRef.current) ro.observe(gridRef.current);
    return () =&gt; ro.disconnect();
  }, []);

  const hotkeys = useMemo(
    () =&gt; ({
      escape: stop,
      r: () =&gt; {
        const i = Math.floor(Math.random() * sounds.length);
        setActive(i);
        refs.current[i]?.focus();
        play(sounds[i]);
      },
    }),
    [sounds, play, stop]
  );
  useHotkeys(hotkeys);

  return (
    &lt;div
      ref={gridRef}
      role="group"
      aria-label="Sound buttons"
      className="grid"
      onKeyDown={onKeyDown}
    &gt;
      {sounds.map((s, i) =&gt; (
        &lt;SoundTile
          key={s.id}
          sound={s}
          playing={playingId === s.id}
          tabIndex={i === active ? 0 : -1}
          onPlay={play}
          onFocus={() =&gt; setActive(i)}
          refCallback={(el) =&gt; (refs.current[i] = el)}
        /&gt;
      ))}
    &lt;/div&gt;
  );
}
</code></pre>
<p>I use <code>role="group"</code> with a label rather than <code>role="grid"</code>. A full ARIA grid implies rows, cells and a lot of extra semantics; a labelled group of toggle buttons with arrow-key navigation is simpler and reads more naturally in screen readers.</p>
<h2>6. Visual details that matter</h2>
<pre><code class="language-css">.tile:focus-visible {
  outline: 3px solid #f59e0b;
  outline-offset: 3px;
}
.tile[aria-pressed="true"] {
  background: #1d4ed8;
  color: white;
}
@media (prefers-reduced-motion: no-preference) {
  .tile[aria-pressed="true"] { animation: pulse 0.8s ease-in-out infinite; }
}
</code></pre>
<ul>
<li>Style <code>:focus-visible</code>, never remove outlines.</li>
<li>Show playing state with colour <strong>and</strong> something else (an icon, a border, motion), so it is not colour-only.</li>
<li>Gate pulsing animations behind <code>prefers-reduced-motion</code>.</li>
<li>Keep a minimum hit target of around 44×44px for touch users.</li>
</ul>
<h2>Testing it</h2>
<ul>
<li>Unplug your mouse and try to find, play and stop a sound in under ten seconds.</li>
<li>Turn on VoiceOver or NVDA and confirm you hear the title, the pressed state and the live announcement.</li>
<li>Run axe or Lighthouse for the basics, then trust your own manual pass more.</li>
</ul>
<p>If you want a real data set to test with, category pages like the <a href="https://www.memetap.pk/soundboards/memes">meme sounds soundboard</a> or the <a href="https://www.memetap.pk/soundboards/reactions">reaction sounds board</a> have dozens of short clips per page, which is exactly where roving focus and a shared player earn their keep. The <a href="https://www.memetap.pk/trending">trending sounds list</a> is handy for checking that your grid copes with titles of wildly different lengths.</p>
<p>A soundboard is a toy. Making it accessible is not much extra work, and it turns a toy that only works for mouse users into one that works for everyone who wants to hit a button and hear a laugh track.</p>
]]></content:encoded></item><item><title><![CDATA[Dynamic OG Images in the Next.js App Router: Build Them, Then Prove They Work]]></title><description><![CDATA[Most Next.js projects start with one static og.png in /public and never revisit it. That is fine until you have a blog, a docs site or a product catalogue, and every shared link shows the same generic]]></description><link>https://abidali671.hashnode.dev/dynamic-og-images-in-the-next-js-app-router-build-them-then-prove-they-work</link><guid isPermaLink="true">https://abidali671.hashnode.dev/dynamic-og-images-in-the-next-js-app-router-build-them-then-prove-they-work</guid><category><![CDATA[Next.js]]></category><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Abid Ali]]></dc:creator><pubDate>Tue, 13 Oct 2026 18:12:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ace6eb9c058e36d175aeff0/d6c93a01-731f-4717-b80a-247952fb5bc7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most Next.js projects start with one static <code>og.png</code> in <code>/public</code> and never revisit it. That is fine until you have a blog, a docs site or a product catalogue, and every shared link shows the same generic banner. The App Router makes per-page Open Graph images easy to generate. What it does not do is tell you whether the result actually works when a crawler from LinkedIn or WhatsApp comes knocking.</p>
<p>This post covers both halves: generating dynamic OG images with <code>next/og</code>, and validating them with a script plus a quick manual check before you ship.</p>
<h2>1. Set <code>metadataBase</code> first</h2>
<p>Almost every broken preview I debug in Next.js comes down to relative URLs. Crawlers need absolute <code>og:image</code> URLs. Next.js can build them for you, but only if you tell it where the site lives.</p>
<pre><code class="language-ts">// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL(
    process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"
  ),
  title: { default: "Field Notes", template: "%s · Field Notes" },
  openGraph: { siteName: "Field Notes", type: "website" },
  twitter: { card: "summary_large_image" },
};
</code></pre>
<p>Two things to note. Set <code>NEXT_PUBLIC_SITE_URL</code> to your production origin (with <code>https://www.</code> if that is your canonical host), and make sure preview deployments use their own URL, or your staging cards will point at production images that do not exist yet.</p>
<h2>2. Generate the image with the <code>opengraph-image</code> convention</h2>
<p>The App Router lets you drop an <code>opengraph-image.tsx</code> next to any route. Next.js turns it into an image endpoint and injects the <code>og:image</code>, <code>og:image:width</code>, <code>og:image:height</code> and <code>og:image:type</code> tags automatically.</p>
<pre><code class="language-tsx">// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";

export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "Field Notes article cover";

export default async function OgImage({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return new ImageResponse(
    (
      &lt;div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          padding: 72,
          background: "linear-gradient(135deg, #0f172a, #1e3a8a)",
          color: "white",
        }}
      &gt;
        &lt;div style={{ fontSize: 28, opacity: 0.8 }}&gt;Field Notes&lt;/div&gt;
        &lt;div style={{ fontSize: 64, fontWeight: 700, lineHeight: 1.1 }}&gt;
          {post?.title ?? "Untitled"}
        &lt;/div&gt;
        &lt;div style={{ fontSize: 26, opacity: 0.7 }}&gt;
          {post?.readingTime ?? ""} · {post?.author ?? ""}
        &lt;/div&gt;
      &lt;/div&gt;
    ),
    size
  );
}
</code></pre>
<p>A few practical notes:</p>
<ul>
<li><strong>Flexbox only.</strong> <code>ImageResponse</code> uses Satori under the hood, which supports a subset of CSS. Every <code>div</code> with more than one child needs <code>display: "flex"</code>. Grid is not supported.</li>
<li><strong>Long titles.</strong> Clamp them. A 120-character title at 64px will overflow. Either reduce font size based on length or truncate with an ellipsis in code.</li>
<li><strong>Fonts.</strong> Custom fonts must be loaded as <code>ArrayBuffer</code> and passed via the <code>fonts</code> option. If you skip this you get the default font, which is fine for a first pass.</li>
<li><strong><code>params</code> is async</strong> in recent Next.js versions, so await it as shown.</li>
</ul>
<p>If you also want a separate X image, add <code>twitter-image.tsx</code> with the same shape. Often re-exporting is enough:</p>
<pre><code class="language-ts">// app/blog/[slug]/twitter-image.tsx
export { default, size, contentType, alt } from "./opengraph-image";
</code></pre>
<h2>3. Keep <code>generateMetadata</code> honest</h2>
<p>The image is only one tag. The title and description that sit beside it come from <code>generateMetadata</code>. Keep them per page and avoid accidentally overriding the generated image.</p>
<pre><code class="language-ts">// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost } from "@/lib/posts";

export async function generateMetadata({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;;
}): Promise&lt;Metadata&gt; {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return { title: "Not found" };

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: {
      type: "article",
      title: post.title,
      description: post.excerpt,
      url: `/blog/${slug}`,
      publishedTime: post.publishedAt,
      // no `images` here: the opengraph-image file supplies it
    },
  };
}
</code></pre>
<p>One trap: if you define <code>openGraph.images</code> here <strong>and</strong> have an <code>opengraph-image.tsx</code>, the file-based image normally wins, but mixing the two makes it hard to reason about. Pick one source per route.</p>
<p>Another trap: when you set <code>openGraph</code> in a child route, it does not deep-merge with the parent. Fields like <code>siteName</code> from the root layout can disappear. Re-declare anything you need, or pull shared fields from a helper.</p>
<h2>4. Validate in CI, not in the group chat</h2>
<p>Now the important part. A green build tells you the route compiled. It tells you nothing about whether a crawler can fetch the image, whether the URL is absolute, or whether the dimensions are right. So I add a small Node script that runs against a deployed preview URL.</p>
<pre><code class="language-ts">// scripts/check-og.ts
// usage: npx tsx scripts/check-og.ts https://preview.example.com/blog/hello
const target = process.argv[2];
if (!target) throw new Error("Pass a URL");

const UA = "Mozilla/5.0 (compatible; og-check/1.0)";

function meta(html: string, key: string): string | undefined {
  const re = new RegExp(
    `&lt;meta[^&gt;]+(?:property|name)=["']${key}["'][^&gt;]*content=["']([^"']+)["']`,
    "i"
  );
  return html.match(re)?.[1];
}

async function main() {
  const res = await fetch(target, { headers: { "user-agent": UA } });
  if (!res.ok) throw new Error(`Page returned ${res.status}`);
  const html = await res.text();

  const required = ["og:title", "og:description", "og:image", "og:url"];
  const found = Object.fromEntries(required.map((k) =&gt; [k, meta(html, k)]));
  const missing = required.filter((k) =&gt; !found[k]);
  if (missing.length) throw new Error(`Missing: ${missing.join(", ")}`);

  const img = found["og:image"]!;
  if (!/^https:\/\//.test(img)) throw new Error(`og:image not absolute https: ${img}`);

  const imgRes = await fetch(img, { headers: { "user-agent": UA } });
  const type = imgRes.headers.get("content-type") ?? "";
  const bytes = Number(imgRes.headers.get("content-length") ?? 0);
  if (!imgRes.ok || !type.startsWith("image/"))
    throw new Error(`Image failed: ${imgRes.status} ${type}`);
  if (bytes &gt; 5_000_000) console.warn(`Warning: image is ${bytes} bytes`);

  const title = found["og:title"]!;
  if (title.length &gt; 90) console.warn(`Warning: long og:title (${title.length})`);

  console.log("OK", found);
}

main().catch((e) =&gt; {
  console.error(e.message);
  process.exit(1);
});
</code></pre>
<p>Wire it into the workflow that runs after your preview deployment, and loop over a handful of representative URLs: home, one blog post, one product page. It catches the boring failures early: <code>localhost</code> in <code>og:image</code>, a 500 from the image route because a post had no author, or a missing description on a new template.</p>
<p>What it cannot show you is how the card actually looks on each platform. Crop ratios, title truncation and fallback behaviour differ between LinkedIn, WhatsApp, Slack and X.</p>
<h2>5. Do one visual pass before launch</h2>
<p>For the visual side I paste the deployed URL into <a href="https://www.ogscope.pk/">OG Scope's Open Graph checker</a>. It is a free tool I built because I kept doing this check by hand. It fetches the page server-side, without running your client JavaScript (which matches how most crawlers behave), lists the tags it found, and draws estimated cards for LinkedIn, WhatsApp, Facebook, X, Slack, Discord and Telegram.</p>
<p>Two pages are especially useful for dynamic images:</p>
<ul>
<li>The <a href="https://www.ogscope.pk/og-image-checker">OG image checker</a> shows the raw <code>og:image</code> value you published next to the resolved URL, confirms the response is an actual image and reads pixel dimensions from the file. If your <code>opengraph-image</code> route accidentally returns HTML (an error page, say), this is where you see it.</li>
<li>The <a href="https://www.ogscope.pk/twitter-card-checker">Twitter card checker</a> shows which <code>twitter:card</code> layout was read and which fields fell back to Open Graph, which helps when <code>twitter-image.tsx</code> and <code>opengraph-image.tsx</code> drift apart.</li>
</ul>
<p>Treat both as estimates. Platforms change their rendering and cache previews aggressively, so after fixing a live page you may still need the platform's own refresh tool or a cache-busting query string.</p>
<h2>A short checklist</h2>
<ul>
<li><code>metadataBase</code> set from an environment variable, correct per environment.</li>
<li>One image source per route: either <code>opengraph-image.tsx</code> or <code>openGraph.images</code>.</li>
<li>1200×630 PNG, titles clamped, fonts loaded explicitly.</li>
<li>Child <code>openGraph</code> objects re-declare shared fields.</li>
<li>A CI script that fetches the page and the image like a crawler would.</li>
<li>One visual pass on the platforms your audience actually uses.</li>
</ul>
<p>Dynamic OG images are one of the nicest features of the App Router. Validating them takes about twenty extra lines and a minute of looking, and it means the first person to share your link sees the card you designed instead of a grey box.</p>
]]></content:encoded></item></channel></rss>