<?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[Freelance Full-Stack Developer | Django + React | Shopify, WordPress & Automation | I Build Web Experiences That Convert]]></title><description><![CDATA[Freelance Full-Stack Developer | Django + React | Shopify, WordPress & Automation | I Build Web Experiences That Convert]]></description><link>https://blog.vicentereyes.org</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1771383249903/464fb860-6f3d-4678-bbe6-bbc63e917af9.png</url><title>Freelance Full-Stack Developer | Django + React | Shopify, WordPress &amp; Automation | I Build Web Experiences That Convert</title><link>https://blog.vicentereyes.org</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 17 May 2026 17:44:19 GMT</lastBuildDate><atom:link href="https://blog.vicentereyes.org/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Deploying Cookiecutter Django on a DigitalOcean Droplet (Ubuntu 24.04 LTS)]]></title><description><![CDATA[A no-fluff deployment runbook for getting a Cookiecutter Django project live on DigitalOcean using Docker and Traefik. Covers the full path from droplet provisioning to a working production deployment]]></description><link>https://blog.vicentereyes.org/deploying-cookiecutter-django-on-a-digitalocean-droplet-ubuntu-24-04-lts</link><guid isPermaLink="true">https://blog.vicentereyes.org/deploying-cookiecutter-django-on-a-digitalocean-droplet-ubuntu-24-04-lts</guid><category><![CDATA[Django]]></category><category><![CDATA[Docker]]></category><category><![CDATA[DigitalOcean]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Sat, 09 May 2026 03:15:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f95116240346172a86c20c6/a5a1532b-deb8-4dea-8258-3536e370c18c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A no-fluff deployment runbook for getting a Cookiecutter Django project live on DigitalOcean using Docker and Traefik. Covers the full path from droplet provisioning to a working production deployment with SSL, plus the gotchas I keep hitting.</p>
<h2>Pre-flight Checklist</h2>
<p>Before touching the droplet, confirm:</p>
<ul>
<li><p>Cookiecutter Django project generated locally with <strong>production = Docker</strong>, <strong>Traefik</strong> (or Nginx), <strong>Postgres</strong>, and your email backend of choice (Mailgun, SendGrid, Anymail, etc.)</p>
</li>
<li><p>Project pushed to a <strong>private GitHub repo</strong></p>
</li>
<li><p>Domain registered with DNS access</p>
</li>
<li><p>DigitalOcean account ready</p>
</li>
<li><p>Local SSH keypair (<code>~/.ssh/id_ed25519</code>) ready</p>
</li>
<li><p>All <code>.envs/.production/*</code> files prepared locally (these are git-ignored and must be transferred separately)</p>
</li>
</ul>
<p><strong>Required env files:</strong></p>
<pre><code class="language-shell">.envs/.production/.django
.envs/.production/.postgres
</code></pre>
<p>Generate strong values for <code>DJANGO_SECRET_KEY</code>, <code>DJANGO_ADMIN_URL</code>, <code>POSTGRES_PASSWORD</code>, etc:</p>
<pre><code class="language-shell">python -c "import secrets; print(secrets.token_urlsafe(64))"
</code></pre>
<h2>1. Spin Up the Droplet</h2>
<ul>
<li><p><strong>Image:</strong> Ubuntu 24.04 (LTS) x64</p>
</li>
<li><p><strong>Plan:</strong> Basic — minimum 2 GB RAM / 1 vCPU. Postgres + Django + Traefik + Redis on 1 GB will OOM during builds.</p>
</li>
<li><p><strong>Auth:</strong> SSH key (paste your <code>~/.ssh/id_ed25519.pub</code>)</p>
</li>
<li><p><strong>Region:</strong> Closest to your users</p>
</li>
<li><p><strong>Hostname:</strong> something descriptive (e.g. <code>myapp-prod-sg1</code>)</p>
</li>
</ul>
<p>Note the public IPv4 once it's provisioned.</p>
<h2>2. DNS Records</h2>
<p>In your domain registrar (or DigitalOcean DNS):</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Value</th>
<th>TTL</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>@</td>
<td><code>&lt;droplet_ip&gt;</code></td>
<td>3600</td>
</tr>
<tr>
<td>A</td>
<td>www</td>
<td><code>&lt;droplet_ip&gt;</code></td>
<td>3600</td>
</tr>
</tbody></table>
<p>Traefik will provision Let's Encrypt SSL automatically once DNS resolves and ports 80/443 are open. <strong>Wait for DNS to propagate</strong> before bringing up the stack — otherwise Let's Encrypt will rate-limit you on failed challenges.</p>
<pre><code class="language-shell">dig yourdomain.com +short
</code></pre>
<h2>3. Initial Server Hardening</h2>
<h3>Connect as root</h3>
<pre><code class="language-shell">ssh root@&lt;droplet_ip&gt;
</code></pre>
<h3>Update packages</h3>
<pre><code class="language-shell">apt update &amp;&amp; apt upgrade -y
</code></pre>
<h3>Create a non-root user</h3>
<p>Replace <code>&lt;username&gt;</code> with your chosen username throughout this guide.</p>
<pre><code class="language-shell">adduser &lt;username&gt;
usermod -aG sudo &lt;username&gt;
</code></pre>
<h3>Mirror SSH keys to the new user</h3>
<pre><code class="language-shell">rsync --archive --chown=&lt;username&gt;:&lt;username&gt; ~/.ssh /home/&lt;username&gt;
</code></pre>
<h3>Configure UFW</h3>
<pre><code class="language-shell">ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
</code></pre>
<p>Port 80 needs to be open (not just 443) because Traefik uses it for the Let's Encrypt HTTP-01 challenge and to redirect HTTP traffic to HTTPS.</p>
<h3>Reconnect as the new user</h3>
<pre><code class="language-shell">exit
ssh &lt;username&gt;@&lt;droplet_ip&gt;
</code></pre>
<h3>Lock down SSH</h3>
<pre><code class="language-shell">sudo nano /etc/ssh/sshd_config
</code></pre>
<p>Set:</p>
<pre><code class="language-shell">PermitRootLogin no
PasswordAuthentication no
</code></pre>
<p>Reload SSH (no full reboot needed):</p>
<pre><code class="language-shell">sudo systemctl reload ssh
</code></pre>
<p>Test from a <strong>new terminal</strong> before closing the current session — if you locked yourself out, the live session is your only way back in.</p>
<h2>4. Install Docker &amp; Compose Plugin</h2>
<pre><code class="language-shell"># Remove any old versions
sudo apt remove -y docker docker-engine docker.io containerd runc

# Install dependencies
sudo apt install -y ca-certificates curl gnupg lsb-release

# Add Docker's GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

# Add the repo
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null

# Install
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# Allow your user to run docker without sudo
sudo usermod -aG docker $USER
newgrp docker

# Verify
docker --version
docker compose version
</code></pre>
<h2>5. Set Up GitHub Deploy Key</h2>
<p>Since the repo is private, the droplet needs SSH access to clone and pull.</p>
<pre><code class="language-shell">ssh-keygen -t ed25519 -C "&lt;username&gt;@yourdomain.com"
# Press enter through prompts (no passphrase, default location)
cat ~/.ssh/id_ed25519.pub
</code></pre>
<p>Copy the output and add it as a <strong>deploy key</strong> on the GitHub repo:</p>
<p><code>Settings → Deploy keys → Add deploy key</code></p>
<p>Read-only access is fine unless you're pushing from the server.</p>
<p>Test the connection:</p>
<pre><code class="language-shell">ssh -T git@github.com
</code></pre>
<h2>6. Clone the Project</h2>
<pre><code class="language-shell">cd ~
git clone git@github.com:&lt;your-username&gt;/&lt;your-repo&gt;.git
cd &lt;your-repo&gt;
</code></pre>
<h2>7. Transfer Production Env Files</h2>
<p>The <code>.envs/.production/</code> folder is git-ignored, so SCP it from local:</p>
<p><strong>From your local machine:</strong></p>
<pre><code class="language-shell">scp -r .envs/.production &lt;username&gt;@&lt;droplet_ip&gt;:~/&lt;your-repo&gt;/.envs/
</code></pre>
<p>Verify on the droplet:</p>
<pre><code class="language-shell">ls -la ~/&lt;your-repo&gt;/.envs/.production/
# Should show .django and .postgres
</code></pre>
<p>Lock down permissions:</p>
<pre><code class="language-shell">chmod 600 ~/&lt;your-repo&gt;/.envs/.production/.django
chmod 600 ~/&lt;your-repo&gt;/.envs/.production/.postgres
</code></pre>
<h2>8. Configure Production Domain</h2>
<h3>Update <code>.envs/.production/.django</code></h3>
<pre><code class="language-shell">nano .envs/.production/.django
</code></pre>
<p>Critical variables:</p>
<pre><code class="language-shell">DJANGO_ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
DJANGO_SECURE_SSL_REDIRECT=True
DJANGO_SERVER_EMAIL=noreply@yourdomain.com
DJANGO_DEFAULT_FROM_EMAIL=hello@yourdomain.com
MAILGUN_API_KEY=&lt;key&gt;
MAILGUN_DOMAIN=mg.yourdomain.com
</code></pre>
<h3>Update <code>compose/production/traefik/traefik.yml</code></h3>
<pre><code class="language-shell">nano compose/production/traefik/traefik.yml
</code></pre>
<p>Replace every instance of the placeholder domain with yours. Look for:</p>
<pre><code class="language-yaml">- "Host(`example.com`) || Host(`www.example.com`)"
</code></pre>
<p>And the Let's Encrypt email:</p>
<pre><code class="language-yaml">email: "your_real_email@yourdomain.com"
</code></pre>
<blockquote>
<p>Use a real, monitored email — Let's Encrypt sends expiry warnings here.</p>
</blockquote>
<h2>9. Build and Launch</h2>
<pre><code class="language-shell">docker compose -f docker-compose.production.yml up --build -d
</code></pre>
<p>First build takes 5–10 minutes. Watch logs:</p>
<pre><code class="language-shell">docker compose -f docker-compose.production.yml logs -f
</code></pre>
<p><strong>What to look for:</strong></p>
<ul>
<li><p><code>traefik</code> should successfully obtain Let's Encrypt cert (search logs for <code>certificate obtained</code>)</p>
</li>
<li><p><code>django</code> should boot without import errors</p>
</li>
<li><p><code>postgres</code> should be ready and accepting connections</p>
</li>
</ul>
<p><strong>Common first-run failures:</strong></p>
<ul>
<li><p><strong>Cert acquisition fails</strong> → DNS hasn't propagated yet, or port 80 is blocked</p>
</li>
<li><p><strong>Django can't connect to DB</strong> → <code>.envs/.production/.postgres</code> mismatch</p>
</li>
<li><p><strong>502 from Traefik</strong> → Django container crashed, check <code>logs django</code></p>
</li>
</ul>
<h2>10. Run Migrations &amp; Create Superuser</h2>
<pre><code class="language-shell"># Migrations
docker compose -f docker-compose.production.yml run --rm django python manage.py migrate

# Superuser
docker compose -f docker-compose.production.yml run --rm django python manage.py createsuperuser

# Collect static (usually handled at build time, but run if needed)
docker compose -f docker-compose.production.yml run --rm django python manage.py collectstatic --noinput
</code></pre>
<blockquote>
<p><strong>Never run</strong> <code>makemigrations</code> <strong>on the server.</strong> Generate migrations locally, commit them, pull on the server, then <code>migrate</code>.</p>
</blockquote>
<h2>11. Update the Sites Framework</h2>
<p>Cookiecutter Django uses Django's Sites framework (especially for django-allauth email links). Update the default site:</p>
<pre><code class="language-shell">docker compose -f docker-compose.production.yml run --rm django python manage.py shell
</code></pre>
<pre><code class="language-python">from django.contrib.sites.models import Site
site = Site.objects.get(pk=1)
site.domain = "yourdomain.com"
site.name = "Your App"
site.save()
exit()
</code></pre>
<h2>12. Smoke Test</h2>
<ul>
<li><p><code>https://yourdomain.com</code> loads with valid SSL (no warnings)</p>
</li>
<li><p><code>https://yourdomain.com/&lt;DJANGO_ADMIN_URL&gt;/</code> → admin login works</p>
</li>
<li><p>Sign up flow → confirmation email arrives</p>
</li>
<li><p>Password reset email arrives</p>
</li>
<li><p>Static files serving (CSS/JS load, no 404s in DevTools)</p>
</li>
<li><p><code>http://yourdomain.com</code> redirects to <code>https://</code></p>
</li>
</ul>
<h2>13. Updating Deployments</h2>
<p>For subsequent deploys:</p>
<pre><code class="language-shell">cd ~/&lt;your-repo&gt;
git pull
docker compose -f docker-compose.production.yml up --build -d
docker compose -f docker-compose.production.yml run --rm django python manage.py migrate
</code></pre>
<p>If you changed env vars, restart the django service:</p>
<pre><code class="language-shell">docker compose -f docker-compose.production.yml restart django
</code></pre>
<h2>14. Backups</h2>
<p>Cookiecutter Django ships with a <code>backup</code> command for Postgres:</p>
<pre><code class="language-shell"># Create backup
docker compose -f docker-compose.production.yml exec postgres backup

# List backups
docker compose -f docker-compose.production.yml exec postgres backups

# Restore (replace with actual backup filename)
docker compose -f docker-compose.production.yml exec postgres restore backup_2026_05_09T00_00_00.sql.gz
</code></pre>
<p>Add a cron job for daily backups + offsite sync to S3 / DO Spaces:</p>
<pre><code class="language-shell">crontab -e
</code></pre>
<pre><code class="language-shell">0 3 * * * cd /home/&lt;username&gt;/&lt;your-repo&gt; &amp;&amp; docker compose -f docker-compose.production.yml exec -T postgres backup &gt;&gt; /home/&lt;username&gt;/backup.log 2&gt;&amp;1
</code></pre>
<h2>15. Troubleshooting Cheatsheet</h2>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Likely Cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td>500 on signup/login</td>
<td>Email backend misconfigured</td>
<td>Check Mailgun/SendGrid keys in <code>.envs/.production/.django</code></td>
</tr>
<tr>
<td>502 Bad Gateway</td>
<td>Django container down</td>
<td><code>docker compose ... logs django</code></td>
</tr>
<tr>
<td>SSL cert not issued</td>
<td>DNS not propagated, port 80 blocked, or Let's Encrypt rate limit</td>
<td>Wait, check <code>ufw status</code>, check Traefik logs</td>
</tr>
<tr>
<td>Static files 404</td>
<td><code>collectstatic</code> not run, or whitenoise misconfigured</td>
<td>Re-run collectstatic, check <code>STATIC_ROOT</code></td>
</tr>
<tr>
<td><code>ALLOWED_HOSTS</code> error</td>
<td>Domain missing from env</td>
<td>Add to <code>DJANGO_ALLOWED_HOSTS</code>, restart django</td>
</tr>
<tr>
<td>OOM during build</td>
<td>Droplet too small</td>
<td>Resize to 2GB+ or build images locally and push to registry</td>
</tr>
<tr>
<td><code>permission denied</code> on docker socket</td>
<td>User not in docker group</td>
<td><code>sudo usermod -aG docker $USER &amp;&amp; newgrp docker</code></td>
</tr>
</tbody></table>
<h2>16. Next Steps (Optional Hardening)</h2>
<ul>
<li><p><strong>CI/CD:</strong> GitHub Actions workflow → SSH into droplet → <code>git pull &amp;&amp; docker compose up --build -d</code>. Use repo secrets for the SSH key.</p>
</li>
<li><p><strong>Monitoring:</strong> Sentry (already wired in Cookiecutter Django) + Uptime Robot for external checks.</p>
</li>
<li><p><strong>Logs:</strong> Ship to a service (Logtail, Papertrail, Datadog) instead of relying on <code>docker logs</code>.</p>
</li>
<li><p><strong>Secrets management:</strong> Move from <code>.env</code> files to Doppler, Infisical, or DO's encrypted env vars for team workflows.</p>
</li>
<li><p><strong>Database:</strong> Move Postgres off the droplet to DO Managed Postgres once you have real traffic. Update <code>DATABASE_URL</code> and you're done.</p>
</li>
<li><p><strong>CDN:</strong> Serve static/media from DO Spaces + a CDN edge.</p>
</li>
</ul>
<h2>Wrapping Up</h2>
<p>Your Django app should now be live behind HTTPS, with auto-renewing SSL, a hardened server, and a clear path for future deploys and backups. From here, the obvious next investments are CI/CD, observability, and moving your database to a managed service once traffic justifies it.</p>
]]></content:encoded></item><item><title><![CDATA[Automating Cloudflare WARP Based on WiFi SSID (Linux Guide)]]></title><description><![CDATA[If you’re switching between networks with different trust levels—home, café, coworking space—you probably don’t want your VPN behavior to be static.
This guide walks through a clean, system-level way ]]></description><link>https://blog.vicentereyes.org/automating-cloudflare-warp-based-on-wifi-ssid-linux-guide</link><guid isPermaLink="true">https://blog.vicentereyes.org/automating-cloudflare-warp-based-on-wifi-ssid-linux-guide</guid><category><![CDATA[Linux]]></category><category><![CDATA[automation]]></category><category><![CDATA[networking]]></category><category><![CDATA[vpn]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Wed, 06 May 2026 05:53:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f95116240346172a86c20c6/6bb7d31a-9f88-478b-84cb-9d94f8f51d5e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you’re switching between networks with different trust levels—home, café, coworking space—you probably don’t want your VPN behavior to be static.</p>
<p>This guide walks through a clean, system-level way to <strong>automatically connect or disconnect Cloudflare WARP depending on your WiFi network name (SSID)</strong> using NetworkManager on Linux.</p>
<hr />
<h2>🧠 Why This Matters</h2>
<p>Not all networks are equal:</p>
<ul>
<li><p>🏠 <strong>Trusted WiFi (Home)</strong> → You may not need WARP</p>
</li>
<li><p>☕ <strong>Public WiFi</strong> → You <em>definitely</em> want WARP</p>
</li>
<li><p>🏢 <strong>Office networks</strong> → Might conflict with VPN routing</p>
</li>
</ul>
<p>Instead of manually toggling WARP every time, we can hook into <strong>network state changes</strong> and automate it.</p>
<hr />
<h2>⚙️ How It Works</h2>
<p>Linux systems using NetworkManager support <strong>dispatcher scripts</strong>—these are triggered automatically when network events occur (e.g., connecting to WiFi).</p>
<p>We leverage this to:</p>
<ol>
<li><p>Detect the current SSID</p>
</li>
<li><p>Apply conditional logic</p>
</li>
<li><p>Toggle WARP accordingly via CLI</p>
</li>
</ol>
<hr />
<h2>🔧 Step-by-Step Implementation</h2>
<h3>1. Ensure WARP CLI is Installed</h3>
<p>You should already have <code>warp-cli</code> available. If not, install Cloudflare WARP first.</p>
<p>Then register and test:</p>
<pre><code class="language-shell">warp-cli register
warp-cli connect
warp-cli status
</code></pre>
<hr />
<h3>2. Create a NetworkManager Dispatcher Script</h3>
<p>Dispatcher scripts live here:</p>
<pre><code class="language-shell">/etc/NetworkManager/dispatcher.d/
</code></pre>
<p>Create a new script:</p>
<pre><code class="language-shell">sudo nano /etc/NetworkManager/dispatcher.d/99-warp-toggle
</code></pre>
<hr />
<h3>3. Add Logic Based on SSID</h3>
<p>Paste the following:</p>
<pre><code class="language-shell">#!/bin/bash

INTERFACE="$1"
STATUS="$2"

# Trigger only when a connection is established
if [ "$STATUS" = "up" ]; then
    SSID=$(iwgetid -r)

    if [ "$SSID" = "home_wifi" ]; then
        echo "Connecting WARP for $SSID"
        warp-cli connect

    elif [ "$SSID" = "office_wifi" ]; then
        echo "Disconnecting WARP for $SSID"
        warp-cli disconnect

    else
        echo "Unknown network: $SSID — no action taken"
    fi
fi
</code></pre>
<hr />
<h3>4. Make the Script Executable</h3>
<pre><code class="language-shell">sudo chmod +x /etc/NetworkManager/dispatcher.d/99-warp-toggle
</code></pre>
<hr />
<h3>5. Apply Changes</h3>
<p>Restart NetworkManager:</p>
<pre><code class="language-shell">sudo systemctl restart NetworkManager
</code></pre>
<p>Or simply reconnect your WiFi.</p>
<hr />
<h2>🧪 Testing</h2>
<p>Switch between your networks:</p>
<ul>
<li><p>Connect to <code>home_wifi</code> → WARP should <strong>connect</strong></p>
</li>
<li><p>Connect to <code>office_wifi</code> → WARP should <strong>disconnect</strong></p>
</li>
</ul>
<p>Verify with:</p>
<pre><code class="language-shell">warp-cli status
</code></pre>
<hr />
<h2>⚠️ Things to Watch Out For</h2>
<ul>
<li><p><strong>SSID detection relies on</strong> <code>iwgetid</code> — ensure it’s installed</p>
</li>
<li><p>Dispatcher scripts run as <strong>root</strong>, so be careful with permissions and logging</p>
</li>
<li><p>Some networks may <strong>block WARP traffic</strong>, causing connection failures</p>
</li>
<li><p>Avoid adding too many rapid toggles (though WARP CLI is fairly tolerant)</p>
</li>
</ul>
<hr />
<h2>🧩 Optional Enhancements</h2>
<p>If you want to level this up:</p>
<h3>🔹 Add Logging</h3>
<pre><code class="language-shell">echo "\((date): Connected to \)SSID" &gt;&gt; /var/log/warp-toggle.log
</code></pre>
<h3>🔹 Handle More Networks</h3>
<p>Expand your conditions into a case statement:</p>
<pre><code class="language-shell">case "$SSID" in
  "home_wifi")
    warp-cli connect
    ;;
  "office_wifi")
    warp-cli disconnect
    ;;
esac
</code></pre>
<h3>🔹 Default Behavior</h3>
<p>Set a fallback (e.g., always connect WARP unless explicitly disabled)</p>
<hr />
<h2>💡 Final Thoughts</h2>
<p>This approach is powerful because it’s:</p>
<ul>
<li><p><strong>Event-driven</strong> (no polling loops)</p>
</li>
<li><p><strong>Lightweight</strong> (no extra services needed)</p>
</li>
<li><p><strong>Extensible</strong> (you can hook in more automations)</p>
</li>
</ul>
<p>You’re essentially turning your machine into a <strong>context-aware system</strong>—reacting intelligently to its environment.</p>
<p>Once you get comfortable with dispatcher scripts, this pattern opens up a lot of automation possibilities beyond VPNs.</p>
<hr />
<p>If you’re building out a more advanced workflow system (especially with tools like n8n or custom daemons), this can serve as a solid foundation for network-aware automation.</p>
<p>🚀</p>
]]></content:encoded></item><item><title><![CDATA[Hunting Disk Hogs on Ubuntu: A Shell Script for Finding the Largest Files]]></title><description><![CDATA[Why this script exists
If you've ever watched your free disk space quietly shrink over a few weeks of active development, you know the feeling: yesterday you had plenty of headroom, today your IDE is ]]></description><link>https://blog.vicentereyes.org/hunting-disk-hogs-on-ubuntu-a-shell-script-for-finding-the-largest-files</link><guid isPermaLink="true">https://blog.vicentereyes.org/hunting-disk-hogs-on-ubuntu-a-shell-script-for-finding-the-largest-files</guid><category><![CDATA[Linux]]></category><category><![CDATA[Ubuntu]]></category><category><![CDATA[Bash]]></category><category><![CDATA[Devops]]></category><category><![CDATA[sysadmin]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Tue, 21 Apr 2026 13:57:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f95116240346172a86c20c6/4e2543ca-931e-4d31-8bd5-04f6072a2cd4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2><strong>Why this script exists</strong></h2>
<p>If you've ever watched your free disk space quietly shrink over a few weeks of active development, you know the feeling: yesterday you had plenty of headroom, today your IDE is yelling about low disk space, and you have no idea what ate the difference. Active Node.js and Python projects are especially good at this — <code>node_modules</code>, build caches, <code>.next</code> directories, virtual environments, and compiled artifacts accumulate silently with every install and every build.</p>
<p>This article walks through a bash script, <code>find_largest_</code><a href="http://files.sh"><code>files.sh</code></a>, that scans a directory tree and writes the largest files to a timestamped text report. It's designed to be a first diagnostic tool when you're trying to answer the question <em>"where did all my disk space go?"</em></p>
<h2><strong>The script at a glance</strong></h2>
<p>The full script is in <code>find_largest_</code><a href="http://files.sh"><code>files.sh</code></a>. Here's what it does, step by step:</p>
<ol>
<li><p>Takes three optional arguments: search directory, number of results, and output filename.</p>
</li>
<li><p>Validates that the search directory exists and that the count is a positive integer.</p>
</li>
<li><p>Writes a header to the output file with timestamp, host, user, and scan parameters.</p>
</li>
<li><p>Uses <code>find</code> to list every regular file under the target directory, along with its size in bytes.</p>
</li>
<li><p>Sorts the list numerically by size (largest first), takes the top N, and converts byte counts into human-readable units (K/M/G/T).</p>
</li>
<li><p>Appends the formatted list to the report and prints it to your terminal.</p>
</li>
</ol>
<h2><strong>Key design decisions</strong></h2>
<h3><strong>Using</strong> <code>find -printf</code> <strong>instead of</strong> <code>ls</code> <strong>or</strong> <code>du</code></h3>
<pre><code class="language-shell">find "$SEARCH_DIR" -type f -printf '%s\t%p\n'
</code></pre>
<p><code>find -printf</code> outputs size (<code>%s</code>) in bytes and the full path (<code>%p</code>), tab-separated. This matters for three reasons: byte-level precision means sorting stays accurate; tab separation survives filenames with spaces; and restricting to <code>-type f</code> means we report on actual files, not directory aggregates the way <code>du</code> would.</p>
<h3><strong>Pruning pseudo-filesystems</strong></h3>
<pre><code class="language-shell">\( -path /proc -o -path /sys -o -path /dev -o -path /run -o -path /snap \) -prune
</code></pre>
<p><code>/proc</code>, <code>/sys</code>, <code>/dev</code>, and <code>/run</code> are kernel-provided virtual filesystems. They contain "files" whose reported sizes are often meaningless (a <code>/proc/kcore</code> can appear to be 128 TB). <code>/snap</code> is pruned because snap mount points produce duplicate entries. Skipping all five keeps the report focused on real files on your actual disk.</p>
<h3><strong>Silencing permission errors</strong></h3>
<pre><code class="language-shell">2&gt;/dev/null
</code></pre>
<p>On a full <code>/</code> scan, <code>find</code> will hit directories your user can't read and print <code>Permission denied</code> for every one of them — noise that can bury the real output. Redirecting stderr to <code>/dev/null</code> cleans that up. Run the script with <code>sudo</code> if you want complete coverage.</p>
<h3><strong>Human-readable sizes in</strong> <code>awk</code><strong>, not</strong> <code>find</code></h3>
<p>We sort the raw byte counts first, <em>then</em> format them in <code>awk</code>. If we formatted early (e.g. via <code>find ... | sort -h</code>), we'd either give up precision or depend on <code>sort -h</code> parsing variants. Keeping bytes for sorting and converting after is simpler and portable.</p>
<h2><strong>Running it against your scenario</strong></h2>
<p>You mentioned roughly 30 GB of disk disappeared recently while you've been building <code>mortgage_system</code> and <code>mortgage_frontend</code> with Claude. Those kinds of projects are classic sources of silent disk bloat. Here's how I'd approach the investigation.</p>
<h3><strong>Step 1: Get the big picture</strong></h3>
<p>Start at root to confirm whether the missing space is actually inside your project folders, or somewhere else entirely (logs, Docker, snap revisions, trash, etc.).</p>
<pre><code class="language-shell">sudo ./find_largest_files.sh / 50 full_scan.txt
</code></pre>
<p>This gives you the 50 biggest files system-wide. If most of them are under <code>/home/you/mortgage_system/...</code> or <code>/home/you/mortgage_frontend/...</code>, your instinct was right. If the top entries are elsewhere — <code>/var/lib/docker</code>, <code>/var/log</code>, <code>~/.cache</code>, snap backups — the real culprit is somewhere you weren't looking.</p>
<h3><strong>Step 2: Focus on the suspects</strong></h3>
<p>Once you've confirmed the project folders are the problem, narrow the scan:</p>
<pre><code class="language-shell">./find_largest_files.sh ~/mortgage_system 30 mortgage_system_report.txt
./find_largest_files.sh ~/mortgage_frontend 30 mortgage_frontend_report.txt
</code></pre>
<h3><strong>Step 3: Check directory-level size too</strong></h3>
<p>Individual largest files tell one story; directory totals tell another. A million tiny files in <code>node_modules</code> won't show up in a largest-files report, but they'll still eat gigabytes. Pair the script with <code>du</code>:</p>
<pre><code class="language-shell">du -h --max-depth=1 ~/mortgage_system | sort -rh | head -20
du -h --max-depth=1 ~/mortgage_frontend | sort -rh | head -20
</code></pre>
<h2><strong>Common culprits in active dev folders</strong></h2>
<p>Based on the stack you're likely using, here are the usual suspects, roughly in order of how often they're the answer:</p>
<ul>
<li><p><code>node_modules/</code> — routinely 500 MB to 2 GB <em>per project</em>. Two full Next.js/React projects with heavy dependency trees can easily account for 3–5 GB each.</p>
</li>
<li><p><code>.next/</code> <strong>or</strong> <code>dist/</code> <strong>or</strong> <code>build/</code> — production builds and incremental build caches. Next.js's <code>.next/cache</code> in particular can grow to several GB over weeks of <code>npm run dev</code>.</p>
</li>
<li><p><code>.git/</code> — if you've committed large binaries or have a long history, <code>.git/objects</code> can be surprisingly fat. <code>git gc --aggressive</code> helps.</p>
</li>
<li><p><strong>Python</strong> <code>__pycache__/</code> <strong>and</strong> <code>.venv/</code> — virtual environments with ML/data dependencies (torch, tensorflow, pandas) are often 3–8 GB each.</p>
</li>
<li><p><strong>Docker layers</strong> — <code>/var/lib/docker</code> is the single most common "where did my disk go?" answer on dev machines. <code>docker system df</code> shows the breakdown; <code>docker system prune -a</code> reclaims it.</p>
</li>
<li><p><strong>Log files</strong> — <code>/var/log/journal/</code>, application logs, and PM2 logs can grow indefinitely if no rotation is configured.</p>
</li>
<li><p><strong>Snap revisions</strong> — Ubuntu keeps old snap versions by default. <code>sudo snap set system refresh.retain=2</code> caps retention at two revisions.</p>
</li>
<li><p><strong>Trash</strong> — <code>~/.local/share/Trash/</code> is easy to forget.</p>
</li>
<li><p><strong>Browser caches</strong> — <code>~/.cache/google-chrome</code>, <code>~/.cache/mozilla</code>, and similar can hit several GB.</p>
</li>
</ul>
<p>For Node projects specifically, a quick sanity check:</p>
<pre><code class="language-shell">du -sh ~/mortgage_system/node_modules ~/mortgage_frontend/node_modules 2&gt;/dev/null
</code></pre>
<p>If each is over a gigabyte, that's your ~30 GB budget explained between two projects, their build caches, and a <code>.git</code> folder or two.</p>
<h2><strong>Useful companion commands</strong></h2>
<pre><code class="language-shell"># Overall disk usage at a glance
df -h

# Top-level directories sorted by size (run from /)
sudo du -h --max-depth=1 / 2&gt;/dev/null | sort -rh | head -20

# What's eating your home directory
du -h --max-depth=1 ~ | sort -rh | head -20

# Docker-specific
docker system df
docker system prune -a --volumes  # aggressive, frees everything unused

# Clean npm cache
npm cache clean --force

# Clean pip cache
pip cache purge

# Clear systemd journal older than 7 days
sudo journalctl --vacuum-time=7d
</code></pre>
<h2><strong>Going further:</strong> <code>ncdu</code> <strong>for interactive exploration</strong></h2>
<p>The script gives you a static report, which is great for archiving and diffing over time. For interactive drilling, install <code>ncdu</code>:</p>
<pre><code class="language-shell">sudo apt install ncdu
ncdu ~
</code></pre>
<p>It gives you a terminal UI where you can navigate directories by size, delete things on the spot, and generally understand disk usage faster than any CLI combination. It's the tool I reach for once the script points me to the neighbourhood and I need to find the exact house.</p>
<h2><strong>Setting up ongoing monitoring</strong></h2>
<p>If you want to catch disk bloat as it happens instead of after the fact, schedule the script via cron and diff the reports:</p>
<pre><code class="language-shell"># Edit your crontab
crontab -e

# Add: run every Sunday at 2 AM, save to a reports directory
0 2 * * 0 /home/you/find_largest_files.sh / 50 /home/you/disk_reports/scan_$(date +\%Y\%m\%d).txt
</code></pre>
<p>A week later, <code>diff</code> two reports to see what grew.</p>
<h2><strong>Resources</strong></h2>
<ul>
<li><p><a href="https://www.gnu.org/software/findutils/manual/html_mono/find.html"><em>GNU findutils manual — find</em></a> <em>— authoritative reference for</em> <code>find</code><em>, including the full</em> <code>-printf</code> <em>format spec.</em></p>
</li>
<li><p><a href="https://man7.org/linux/man-pages/man1/du.1.html"><em>Linux</em> <code>du</code> <em>man page</em></a> <em>— directory-aggregate sizing, the natural complement to this script.</em></p>
</li>
<li><p><a href="https://man7.org/linux/man-pages/man1/df.1.html"><em>Linux</em> <code>df</code> <em>man page</em></a> <em>— filesystem-level free space.</em></p>
</li>
<li><p><a href="https://dev.yorhel.nl/ncdu"><em>ncdu home page</em></a> <em>— interactive disk usage analyzer.</em></p>
</li>
<li><p><a href="https://docs.docker.com/engine/manage-resources/pruning/"><em>Docker: prune unused objects</em></a> <em>— official guide to reclaiming Docker space.</em></p>
</li>
<li><p><a href="https://docs.npmjs.com/cli/v10/commands/npm-cache"><em>npm cache documentation</em></a> <em>— how npm's cache works and how to clean it.</em></p>
</li>
<li><p><a href="https://askubuntu.com/questions/7738/how-to-find-the-largest-ten-files-on-the-hard-drive"><em>Ask Ubuntu: Why is my disk full?</em></a> <em>— community thread with many alternative one-liners.</em></p>
</li>
<li><p><a href="https://wiki.archlinux.org/title/Disk_usage_analyzer"><em>Arch Wiki: Disk usage analyzers</em></a> <em>— concise overview of CLI and GUI tools across the Linux ecosystem.</em></p>
</li>
</ul>
<h2><strong>Summary</strong></h2>
<p>If the script points at <code>node_modules</code> and <code>.next</code> inside your two mortgage projects, that's consistent with the 30 GB of lost disk — two mature JS/TS codebases with their build artifacts can account for exactly that range. The usual remediation is <code>rm -rf node_modules .next</code> in each project, followed by a fresh <code>npm install</code> only on the one you're actively working on. If instead the biggest files live under <code>/var/lib/docker</code> or <code>/var/log</code>, the fix is completely different, which is exactly why running a scan first beats guessing.</p>
]]></content:encoded></item><item><title><![CDATA[Renaming 1000+ Pages Worth of "Levels" Without Losing My Mind]]></title><description><![CDATA[A message from a colleague dropped into my inbox one morning:

Hi Ice, now that we pushed the changes sa site, we need to scour the site for mentions of Level 1, Level 2, Level 1/2 and change them acc]]></description><link>https://blog.vicentereyes.org/renaming-1000-pages-worth-of-levels-without-losing-my-mind</link><guid isPermaLink="true">https://blog.vicentereyes.org/renaming-1000-pages-worth-of-levels-without-losing-my-mind</guid><category><![CDATA[Python]]></category><category><![CDATA[WordPress]]></category><category><![CDATA[software development]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Tue, 21 Apr 2026 05:02:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f95116240346172a86c20c6/89a6ae9f-9848-43b7-a8cc-5fde853cba10.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A message from a colleague dropped into my inbox one morning:</p>
<blockquote>
<p>Hi Ice, now that we pushed the changes sa site, we need to scour the site for mentions of Level 1, Level 2, Level 1/2 and change them accordingly to Level 1 → Intro to Neurofascial Training, Level 1/2 → Intermediate Instability Training, Level 2 → Advanced Neurofascial Training</p>
</blockquote>
<p>On the surface, this looks like a simple find-and-replace job. Read the message, open the WordPress admin, use the search feature, fix each page. Done before lunch.</p>
<p>Then I actually opened the site and pulled the sitemap. <strong>1,074 URLs.</strong></p>
<p>That changes the math pretty quickly. Even if only a fraction of those pages mention "Level" anywhere, manually clicking through every post and page, ctrl-F-ing for three different strings, and then figuring out which instance needs which replacement — that's a full day of soul-crushing work at minimum, and a very real chance of missing something.</p>
<h2><strong>The mapping</strong></h2>
<p>Before anything else, I wrote the rename table down somewhere I couldn't lose it, because a misread mapping here would mean renaming correct text into incorrect text, which is strictly worse than leaving it alone:</p>
<table>
<thead>
<tr>
<th>Old text</th>
<th>New text</th>
</tr>
</thead>
<tbody><tr>
<td>Level 1</td>
<td>Intro to Neurofascial Training</td>
</tr>
<tr>
<td>Level 1/2</td>
<td>Intermediate Instability Training</td>
</tr>
<tr>
<td>Level 2</td>
<td>Advanced Neurofascial Training</td>
</tr>
</tbody></table>
<p>The important ordering detail: "Level 1/2" has to be matched <em>before</em> "Level 1", or a naive find-and-replace would turn "Level 1/2" into "Intro to Neurofascial Training/2". Easy to miss, painful to undo.</p>
<h2><strong>Why not just use WordPress search-replace plugins?</strong></h2>
<p>Plugins like Better Search Replace do exist, and in a simpler world I'd use one. But they operate on the database directly, which means:</p>
<ol>
<li><p>One wrong checkbox and you nuke every "Level 1" across post content, post meta, widget text, theme options, and anywhere else the phrase appears — including places it <em>shouldn't</em> be replaced, like analytics or audit logs.</p>
</li>
<li><p>There's no preview of <em>where</em> each match lives. You're trusting the plugin's count and hoping nothing weird is hiding in a shortcode.</p>
</li>
<li><p>The three strings overlap, so the order of operations matters and plugins don't always let you sequence replacements atomically.</p>
</li>
</ol>
<p>What I actually wanted was a map — a list of every occurrence with enough surrounding context to judge whether it's safe to change, plus a direct link to the WordPress editor for that specific page. The replacement itself I'd still do by hand, but informed by real data.</p>
<h2><strong>The script</strong></h2>
<p>I wrote a Python script that walks the site's sitemap, visits each URL, and searches the rendered HTML for the three patterns using a single regex with word boundaries:</p>
<pre><code class="language-python">LEVEL_PATTERN = re.compile(
    r"\bLevel\s*1\s*/\s*2\b|\bLevel\s*1\b|\bLevel\s*2\b",
    re.IGNORECASE,
)
</code></pre>
<p>Word boundaries matter here. Without <code>\b</code>, a page mentioning "Level 10" or "Level 12-week program" would get flagged as a false "Level 1" match. And the alternation order mirrors the mapping problem above — "Level 1/2" is tried first so it doesn't get shadowed by the simpler "Level 1" pattern.</p>
<p>For each match, the script captures:</p>
<ul>
<li><p>The page URL and <code>&lt;title&gt;</code></p>
</li>
<li><p>The WordPress post ID, extracted from the <code>&lt;body&gt;</code> class (WP adds <code>page-id-123</code> or <code>postid-123</code> automatically)</p>
</li>
<li><p>A direct admin edit link built from that ID: <code>/wp-admin/post.php?post=123&amp;action=edit</code></p>
</li>
<li><p>About 80 characters of context on either side of the match</p>
</li>
<li><p>The HTML tag wrapping the match (<code>h2</code>, <code>li</code>, <code>p</code>, etc.) to help me find it inside the block editor</p>
</li>
</ul>
<p>All of it dumped to a CSV. Rows sorted, duplicates within a page collapsed, system paths like <code>/wp-admin</code> and <code>/wp-json</code> filtered out so the scanner doesn't waste time on pages that aren't user content.</p>
<h2><strong>What the CSV actually gives me</strong></h2>
<p>Instead of 1,074 pages to click through blindly, I now have a focused list of every page that mentions "Level" in any form, with a one-click link to the editor and enough context to know which replacement to apply. The context column is the real magic — I can see a snippet like <code>…our flagship Level 1 class is held every Tuesday…</code> and immediately know it's the class name that needs to become "Intro to Neurofascial Training", not some incidental use of the word "level".</p>
<p>For the rare edge case — a page that mentions "Level 1" in a way that <em>shouldn't</em> be renamed, like historical text or a testimonial quoting someone — I can spot it in the context column and skip that row. That judgment call is exactly the part you don't want a blind database replacement making on your behalf.</p>
<h2><strong>Takeaway</strong></h2>
<p>The temptation with a task like this is to just start clicking. It feels productive. But on a site of any real size, a half hour spent writing a scanner pays for itself almost immediately — not just in saved time, but in the confidence that you actually caught everything and didn't quietly corrupt adjacent content along the way.</p>
<p>Sometimes the most valuable thing automation gives you isn't speed. It's the audit trail.</p>
<h2>References</h2>
<ul>
<li><p><em>New XML Sitemaps Functionality in WordPress 5.5 — Make WordPress Core:</em> <a href="https://make.wordpress.org/core/2020/07/22/new-xml-sitemaps-functionality-in-wordpress-5-5/"><em>https://make.wordpress.org/core/2020/07/22/new-xml-sitemaps-functionality-in-wordpress-5-5/</em></a></p>
</li>
<li><p><em>Sitemaps XML Protocol — sitemaps.org:</em> <a href="https://www.sitemaps.org/protocol.html"><em>https://www.sitemaps.org/protocol.html</em></a></p>
</li>
<li><p><code>body_class()</code> <em>Function Reference — WordPress Developer Resources:</em> <a href="https://developer.wordpress.org/reference/functions/body_class/"><em>https://developer.wordpress.org/reference/functions/body_class/</em></a></p>
</li>
<li><p><code>get_body_class()</code> <em>Function Reference — WordPress Developer Resources:</em> <a href="https://developer.wordpress.org/reference/functions/get_body_class/"><em>https://developer.wordpress.org/reference/functions/get_body_class/</em></a></p>
</li>
<li><p><em>re — Regular expression operations — Python 3 docs:</em> <a href="https://docs.python.org/3/library/re.html"><em>https://docs.python.org/3/library/re.html</em></a></p>
</li>
<li><p><em>Better Search Replace — WordPress Plugin Directory:</em> <a href="https://wordpress.org/plugins/better-search-replace/"><em>https://wordpress.org/plugins/better-search-replace/</em></a></p>
</li>
<li><p><em>Requests: HTTP for Humans —</em> <a href="https://requests.readthedocs.io/"><em>https://requests.readthedocs.io/</em></a></p>
</li>
<li><p><em>Beautiful Soup Documentation — <a href="https://www.crummy.com/software/BeautifulSoup/bs4/doc/">https://www.crummy.com/software/BeautifulSoup/bs4/doc/</a></em></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How to Manually Backup WordPress Sites via SSH]]></title><description><![CDATA[Backing up your WordPress site is one of the most important maintenance tasks you can do as a site owner. While plugins like UpdraftPlus or Jetpack make this easy, knowing how to do it manually via SS]]></description><link>https://blog.vicentereyes.org/how-to-manually-backup-wordpress-sites-via-ssh</link><guid isPermaLink="true">https://blog.vicentereyes.org/how-to-manually-backup-wordpress-sites-via-ssh</guid><category><![CDATA[WordPress]]></category><category><![CDATA[Linux]]></category><category><![CDATA[ssh]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Fri, 17 Apr 2026 06:13:34 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f95116240346172a86c20c6/578797d9-3042-48e9-ba43-83fb1150a0bb.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Backing up your WordPress site is one of the most important maintenance tasks you can do as a site owner. While plugins like UpdraftPlus or Jetpack make this easy, knowing how to do it <strong>manually via SSH</strong> gives you full control — no third-party dependencies, no bloat, just a clean archive you own.</p>
<p>This guide walks you through creating a full file backup of your WordPress site directly from the server using the command line.</p>
<hr />
<h2>Installing the Required Tools</h2>
<p>Before connecting to your server, make sure the necessary tools are installed on your <strong>local machine</strong>.</p>
<h3>macOS</h3>
<p>macOS comes with <code>ssh</code> and <code>scp</code> pre-installed. No action needed — just open Terminal and you're ready to go.</p>
<h3>Linux (Ubuntu/Debian)</h3>
<pre><code class="language-shell">sudo apt update &amp;&amp; sudo apt install openssh-client
</code></pre>
<h3>Windows</h3>
<p><strong>Option A — Windows Subsystem for Linux (WSL)</strong> <em>(recommended)</em>:</p>
<pre><code class="language-shell">wsl --install
</code></pre>
<p>Once WSL is set up, <code>ssh</code> and <code>scp</code> are available inside the Linux shell.</p>
<p><strong>Option B — OpenSSH via PowerShell</strong>:</p>
<pre><code class="language-shell">Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
</code></pre>
<p>After installing, <code>ssh</code> and <code>scp</code> will be available directly in PowerShell or Command Prompt.</p>
<p><strong>Option C — GUI alternative</strong>: Install <a href="https://winscp.net/">WinSCP</a> for a drag-and-drop interface to transfer files instead of using <code>scp</code>.</p>
<h3>On the Server</h3>
<p>Your server should already have <code>tar</code> and <code>mysqldump</code> available. If for any reason they are missing, install them with:</p>
<pre><code class="language-shell"># tar
sudo apt install tar          # Debian/Ubuntu
sudo yum install tar          # CentOS/RHEL

# mysqldump (part of the MySQL client)
sudo apt install mysql-client          # Debian/Ubuntu
sudo yum install mysql                 # CentOS/RHEL
</code></pre>
<hr />
<h2>Prerequisites</h2>
<p>Before you begin, make sure you have:</p>
<ul>
<li><p>SSH access to your server</p>
</li>
<li><p>The server's IP address</p>
</li>
<li><p>Your SSH credentials (username and password, or an SSH key)</p>
</li>
<li><p><code>scp</code> or an SFTP client installed on your local machine</p>
</li>
</ul>
<hr />
<h2>Step 1: SSH Into Your Server</h2>
<p>Open your terminal and connect to your server using SSH. Replace <code>ip_address</code> with your actual server IP:</p>
<pre><code class="language-shell">ssh root@ip_address
</code></pre>
<p>You'll be prompted for your password (or authenticated via SSH key). Once connected, you'll be inside your server's shell.</p>
<blockquote>
<p><strong>Tip:</strong> If you're using a non-root user, replace <code>root</code> with your username (e.g., <code>ssh deploy@192.168.1.100</code>).</p>
</blockquote>
<hr />
<h2>Step 2: Create a Compressed Archive of Your Site</h2>
<p>Your WordPress files typically live inside the <code>public_html</code> directory. The following command creates a compressed <code>.tar.gz</code> archive of the entire folder:</p>
<pre><code class="language-shell">tar -czvf ~/public_html/backup-site.tar.gz -C ~/ public_html
</code></pre>
<h3>What each flag does:</h3>
<table>
<thead>
<tr>
<th>Flag</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>-c</code></td>
<td>Create a new archive</td>
</tr>
<tr>
<td><code>-z</code></td>
<td>Compress with gzip</td>
</tr>
<tr>
<td><code>-v</code></td>
<td>Verbose output (shows files being archived)</td>
</tr>
<tr>
<td><code>-f</code></td>
<td>Specifies the output filename</td>
</tr>
<tr>
<td><code>-C ~/</code></td>
<td>Changes to the home directory before archiving</td>
</tr>
</tbody></table>
<p>The backup file <code>backup-site.tar.gz</code> will be saved inside <code>~/public_html/</code>.</p>
<blockquote>
<p><strong>Note:</strong> Depending on your site size, this may take a few minutes. Large media libraries will increase the archive size significantly.</p>
</blockquote>
<hr />
<h2>Step 3: Download the Backup to Your Local Machine</h2>
<p>Once the archive is created, exit the SSH session and run the following <code>scp</code> command on your <strong>local machine</strong> to download the backup:</p>
<pre><code class="language-shell">scp root@ip_address:~/public_html/backup-site.tar.gz ~/Downloads/
</code></pre>
<p>This securely copies the file from your server to your local <code>~/Downloads/</code> folder.</p>
<blockquote>
<p><strong>Tip:</strong> If you're on Windows, you can use <a href="https://winscp.net/">WinSCP</a> or the built-in <code>scp</code> command in PowerShell/WSL as an alternative.</p>
</blockquote>
<hr />
<h2>Step 4: Don't Forget the Database</h2>
<p>A full WordPress backup requires <strong>both the files and the database</strong>. Your files backup covers themes, plugins, and uploads — but your posts, pages, and settings live in MySQL.</p>
<p>To export your database, run this on the server:</p>
<pre><code class="language-shell">mysqldump -u root -p your_database_name &gt; ~/public_html/backup-db.sql
</code></pre>
<p>Then download it the same way:</p>
<pre><code class="language-shell">scp root@ip_address:~/public_html/backup-db.sql ~/Downloads/
</code></pre>
<p>Replace <code>your_database_name</code> with the database name found in your <code>wp-config.php</code> file.</p>
<hr />
<h2>Step 5: Clean Up the Server</h2>
<p>To avoid using up disk space on your server, delete the backup files after downloading them:</p>
<pre><code class="language-shell">rm ~/public_html/backup-site.tar.gz
rm ~/public_html/backup-db.sql
</code></pre>
<hr />
<h2>Restoring from a Backup</h2>
<p>To restore, simply reverse the process:</p>
<ol>
<li><p>Upload the <code>.tar.gz</code> file back to the server using <code>scp</code></p>
</li>
<li><p>Extract it with:</p>
<pre><code class="language-shell">tar -xzvf backup-site.tar.gz -C ~/
</code></pre>
</li>
<li><p>Import the database with:</p>
<pre><code class="language-shell">mysql -u root -p your_database_name &lt; backup-db.sql
</code></pre>
</li>
</ol>
<hr />
<h2>Final Thoughts</h2>
<p>Manual backups via SSH are reliable, fast, and give you a portable snapshot of your entire site. For production sites, consider automating this process with a cron job or combining it with offsite storage like S3 or Google Drive.</p>
<p>A backup you never tested is a backup you can't trust — make sure to do a test restore at least once.</p>
]]></content:encoded></item><item><title><![CDATA[Post-Mortem: The March 2026 Axios Supply Chain Attack]]></title><description><![CDATA[The Incident
On March 31, 2026, a high-profile supply chain attack targeted Axios, a critical HTTP client for the JavaScript ecosystem. By hijacking a maintainer's NPM account, attackers injected a ma]]></description><link>https://blog.vicentereyes.org/post-mortem-the-march-2026-axios-supply-chain-attack</link><guid isPermaLink="true">https://blog.vicentereyes.org/post-mortem-the-march-2026-axios-supply-chain-attack</guid><category><![CDATA[Security]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Node.js]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Tue, 31 Mar 2026 11:37:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f95116240346172a86c20c6/49c03fb3-e056-497d-bb85-dbadb2e8941d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The Incident</h2>
<p>On March 31, 2026, a high-profile supply chain attack targeted <strong>Axios</strong>, a critical HTTP client for the JavaScript ecosystem. By hijacking a maintainer's NPM account, attackers injected a malicious dependency, <code>plain-crypto-js</code>, which deployed a cross-platform Remote Access Trojan (RAT).</p>
<hr />
<h2>Incident Summary</h2>
<table style="min-width:50px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><td><p><strong>Detail</strong></p></td><td><p><strong>Information</strong></p></td></tr><tr><td><p><strong>Affected Versions</strong></p></td><td><p><code>axios@1.14.1</code>, <code>axios@0.30.4</code></p></td></tr><tr><td><p><strong>Malicious Dependency</strong></p></td><td><p><code>plain-crypto-js@4.2.1</code></p></td></tr><tr><td><p><strong>Payload</strong></p></td><td><p>Cross-platform RAT (Linux, macOS, Windows)</p></td></tr><tr><td><p><strong>C2 Server</strong></p></td><td><p><code>sfrclak.com:8000</code></p></td></tr><tr><td><p><strong>Resolution Window</strong></p></td><td><p>Live for ~3 hours (00:21 – 03:29 UTC)</p></td></tr></tbody></table>

<hr />
<h2>Technical Deep Dive</h2>
<p>The attack bypassed standard security audits by hiding the malicious logic within a sub-dependency. Once installed via a standard <code>npm install</code>, the payload scanned the host machine for:</p>
<ul>
<li><p><strong>Environment Variables:</strong> <code>.env</code> files and active shell exports.</p>
</li>
<li><p><strong>Auth Tokens:</strong> <code>~/.npmrc</code> and <code>~/.aws/credentials</code>.</p>
</li>
<li><p><strong>SSH Keys:</strong> Unprotected private keys in <code>~/.ssh</code>.</p>
</li>
</ul>
<p>Data was exfiltrated via <strong>POST requests</strong> to the <a href="http://sfrclak.com"><code>sfrclak.com</code></a> Command &amp; Control (C2) server.</p>
<hr />
<h2>Remediation &amp; Verification</h2>
<p>To ensure a development environment is sanitized, the following protocol was executed:</p>
<ol>
<li><p><strong>Network Sinkholing:</strong> Manually mapping the C2 domain to <code>127.0.0.1</code> in <code>/etc/hosts</code> to prevent further exfiltration and "kill" the phone-home capability.</p>
</li>
<li><p><strong>Lockfile Audit:</strong> Scanning all local projects for traces of the malicious package using a space-safe search:</p>
<p>Bash</p>
<pre><code class="language-shell">find . -type f \( -name "package-lock.json" -o -name "yarn.lock" \) -print0 | xargs -0 grep "plain-crypto-js"
</code></pre>
</li>
<li><p><strong>Environment Sanitization:</strong> Clearing the global NPM cache and updating tool managers (like <code>mise</code>) to ensure only verified versions are used moving forward.</p>
</li>
</ol>
<blockquote>
<p>[!TIP]</p>
<p><strong>Pro-Tip:</strong> Always use <code>npm audit</code> or tools like <strong>Snyk</strong> to monitor your dependency tree for "hidden" sub-dependencies that do not appear directly in your <code>package.json</code>.</p>
</blockquote>
]]></content:encoded></item><item><title><![CDATA[Sorting Hashnode Series Posts: How to Display the Latest Post First]]></title><description><![CDATA[When you publish a series of articles on your Hashnode blog and consume it via their GraphQL API for a custom portfolio or website, you quickly run into a common roadblock: Hashnode’s API natively ret]]></description><link>https://blog.vicentereyes.org/sorting-hashnode-series-posts-how-to-display-the-latest-post-first</link><guid isPermaLink="true">https://blog.vicentereyes.org/sorting-hashnode-series-posts-how-to-display-the-latest-post-first</guid><category><![CDATA[TypeScript]]></category><category><![CDATA[GraphQL]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Wed, 25 Mar 2026 12:51:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f95116240346172a86c20c6/86b1a01d-5406-480d-87db-274576505e1d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When you publish a series of articles on your Hashnode blog and consume it via their GraphQL API for a custom portfolio or website, you quickly run into a common roadblock: <strong>Hashnode’s API natively returns series posts in chronological order (oldest first)</strong>.</p>
<p>While this makes sense for consecutive tutorials ("Part 1", "Part 2"), it’s significantly less ideal for ongoing series—like my "Learning how to code with AI" series—where readers typically want to see your newest breakthroughs or the latest problem you solved directly at the top of the feed.</p>
<p>Unfortunately, looking at the Hashnode GraphQL Schema (<code>Series.posts</code>), there isn't an out-of-the-box <code>sort: RECENT</code> parameter available. Instead, it demands that you sequentially traverse the cursor paginations starting from the oldest post you've written in that series.</p>
<p>Here's how I solved this issue on my React/Vite portfolio to seamlessly deliver a newest-first reading experience to my users.</p>
<h2>The Problem: Oldest First Pagination</h2>
<p>By default, the standard query to fetch series posts looks like this:</p>
<pre><code class="language-graphql">query Series(\(host: String!, \)slug: String!, \(first: Int!, \)after: String) {
  publication(host: $host) {
    series(slug: $slug) {
      posts(first: \(first, after: \)after) {
        edges {
          node {
            slug
            title
            publishedAt
          }
        }
        pageInfo { endCursor hasNextPage }
      }
    }
  }
}
</code></pre>
<p>This returns standard edge/node responses, but starting with the very first post of the series. To get the newest post to display on page 1 of our UI, we need the <em>end</em> of the dataset instead of the beginning.</p>
<h2>The Solution: A Client-Side Cache &amp; Array Reversal</h2>
<p>Since Hashnode API's <code>first</code> parameter maxes out at <code>20</code> items per request, we can't simply request <code>first: 1000</code> and reverse it in one go. We need to sequentially buffer the entire series.</p>
<p>To keep it blazing fast for users querying consecutive pages, we shouldn't fetch the whole thing from scratch every time they click "Next". Instead, we can introduce a local caching mechanism in our code.</p>
<p>We'll parse through all the posts via a <code>while</code> loop on the initial page load, cache the fully assembled series, <code>.reverse()</code> the data natively in JavaScript, and handle React's generic cursor logic over our local cache instead!</p>
<h3>Re-writing <code>fetchSeries</code> in TypeScript</h3>
<p>Here's my updated <code>lib/hashnode.ts</code>:</p>
<pre><code class="language-typescript">import { BlogPost, BlogSeries, PageInfo } from '../types';

// Let's declare local caches outside the function
const seriesMetaCache: Record&lt;string, Omit&lt;BlogSeries, 'posts'&gt;&gt; = {};
const seriesPostsCache: Record&lt;string, BlogPost[]&gt; = {};

export async function fetchSeries(
  slug: string,
  first = 9,
  after?: string
): Promise&lt;{
  series: BlogSeries &amp; { posts?: { edges: { node: BlogPost }[]; pageInfo: PageInfo } };
} | null&gt; {

  // 1. If no 'after' is provided, we must be loading the series for the first time.
  // We'll hit the Hashnode API repeatedly until we traverse all oldest-first pages.
  if (!after || !seriesPostsCache[slug]) {
    let allPosts: BlogPost[] = [];
    let hasNextPage = true;
    let endCursor: string | undefined = undefined;
    let seriesMeta: Omit&lt;BlogSeries, 'posts'&gt; | null = null;

    while (hasNextPage) {
      const data: any = await gqlFetch(SERIES_QUERY, {
        host: PUBLICATION_HOST,
        slug,
        first: 20,
        after: endCursor
      });

      const publishedSeries = data.publication.series;
      if (!publishedSeries) return null;

      // Persist metadata on the first run
      if (!seriesMeta) {
        const { posts, ...metaRest } = publishedSeries;
        seriesMeta = metaRest;
      }

      const posts = publishedSeries.posts;
      allPosts = allPosts.concat(posts.edges.map((e: any) =&gt; e.node));

      hasNextPage = posts.pageInfo.hasNextPage;
      endCursor = posts.pageInfo.endCursor;
    }

    // 2. Here's the magic trick. Reverse the array so Newest is First.
    seriesPostsCache[slug] = allPosts.reverse();
    if (seriesMeta) {
      seriesMetaCache[slug] = seriesMeta;
    }
  }

  // 3. Grab from cache
  const allPosts = seriesPostsCache[slug];
  const seriesMeta = seriesMetaCache[slug];
  if (!seriesMeta) return null;

  // 4. Implement local slicing based on the provided cursors
  const startIndex = after ? allPosts.findIndex(p =&gt; p.slug === after) + 1 : 0;

  // Safety net in case of invalid cursor
  const validStartIndex = startIndex &gt; 0 ? startIndex : (after ? allPosts.length : 0);

  const slice = allPosts.slice(validStartIndex, validStartIndex + first);
  const nextHasNextPage = validStartIndex + first &lt; allPosts.length;

  // Set the newest 'endCursor' for the client to use on consecutive requests
  const newEndCursor = slice.length &gt; 0 ? slice[slice.length - 1].slug : null;

  // 5. Structure exactly how the calling React component expects
  return {
    series: {
      ...seriesMeta,
      posts: {
        edges: slice.map(node =&gt; ({ node })),
        pageInfo: {
          endCursor: newEndCursor || '',
          hasNextPage: nextHasNextPage
        }
      }
    } as any
  };
}
</code></pre>
<h2>Why this approach is awesome:</h2>
<ol>
<li><p><strong>Zero UI Adjustments</strong>: The React component responsible for consuming the posts (<code>SeriesPage.tsx</code>) doesn’t even know this under-the-hood wizardry is occurring. It passes <code>first</code> and <code>after</code> standardly and receives seamless newest-first data.</p>
</li>
<li><p><strong>Instant "Load More"</strong>: Once the initial batch is traversed during <code>while(hasNextPage)</code>, it acts exactly like an instant static array. Any subsequent "Load More" clicks don't hit the standard network stack; they instantly slice through <code>seriesPostsCache</code> in micro-seconds!</p>
</li>
<li><p><strong>Overcoming GraphQL Limitations</strong>: Sometimes headless CMS or GraphQL servers (like Hashnode's otherwise fantastic API) restrict complex filtering or sorting on nested nodes purely for their own database performance. When you abstract that layer on your backend caching structure, you win full control back.</p>
</li>
</ol>
<p>Now, whenever users visit the series pages in my portfolio, the most recently solved programming challenges are rightfully spotlighted at the very top. What's not to love about building digital experiences that slap?</p>
]]></content:encoded></item><item><title><![CDATA[Building a Seamless JWT Onboarding Flow with React Router v7 and Django]]></title><description><![CDATA[Authentication and onboarding are often the highest-friction points in a new user's journey. If the process is clunky or requires too many redirects, users drop off. Recently, I set out to build a str]]></description><link>https://blog.vicentereyes.org/building-a-seamless-jwt-onboarding-flow-with-react-router-v7-and-django</link><guid isPermaLink="true">https://blog.vicentereyes.org/building-a-seamless-jwt-onboarding-flow-with-react-router-v7-and-django</guid><category><![CDATA[Python]]></category><category><![CDATA[Django]]></category><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Tue, 17 Mar 2026 10:47:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f95116240346172a86c20c6/dde205bd-3e48-4afb-9d41-533c71517282.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Authentication and onboarding are often the highest-friction points in a new user's journey. If the process is clunky or requires too many redirects, users drop off. Recently, I set out to build a streamlined <strong>JWT authentication and onboarding flow</strong> using a modern stack: R<strong>eact Router v7 (with Vite), TypeScript, and Django REST Framework (DRF)</strong>.</p>
<p>In this article, I'll walk through the architecture, the backend implementation, and how to handle protected routes securely on the client side.</p>
<h2>The Architecture Stack</h2>
<p>Here is what we are working with:</p>
<ul>
<li><p><strong>Backend</strong>: Django, Django REST Framework (DRF), and djangorestframework-simplejwt.</p>
</li>
<li><p><strong>Frontend</strong>: React (via Vite) and the newly released React Router v7.</p>
</li>
<li><p><strong>Styling</strong>: Tailwind CSS.</p>
</li>
<li><p><strong>State Management</strong>: React Context API for lightweight auth state handling.</p>
</li>
</ul>
<h3><strong>The Goal</strong></h3>
<ol>
<li><p>A user registers.</p>
</li>
<li><p>The user logs in. They receive a short-lived access token and a refresh token.</p>
</li>
<li><p>The backend knows if the user has completed their profile setup (<code>is_onboarded</code>).</p>
</li>
<li><p>If they haven't onboarded, the backend middleware blocks API requests, and the frontend automatically reroutes them to the <code>/onboarding</code> page.</p>
</li>
<li><p>Once onboarded, new tokens are issued with the updated status, and the user gains full access to the application <code>/</code>.</p>
</li>
</ol>
<h2><strong>1. Setting up the Django Backend</strong></h2>
<h3><strong>The Custom User Model</strong></h3>
<p>First, we need to track whether a user has finished setting up their profile. We extend Django's <code>AbstractUser</code>:</p>
<pre><code class="language-python"># users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    is_onboarded = models.BooleanField(default=False)
</code></pre>
<h3><strong>Customizing the JWT Payload</strong></h3>
<p>By default, SimpleJWT only includes the <code>user_id</code> inside the token. We want the frontend to immediately know the user's name and onboarding status without making an extra API call.</p>
<pre><code class="language-python"># users/serializers.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer

class MyTokenObtainPairSerializer(TokenObtainPairSerializer): 
    @classmethod 
    def get_token(cls, user): 
        token = super().get_token(user) 
        # Inject custom claims into the JWT payload 
        token['is_onboarded'] = user.is_onboarded 
        token['username'] = user.username 
        return token
</code></pre>
<h3><strong>Enforcing Onboarding with Middleware</strong></h3>
<p>We want to strictly prevent un-onboarded users from accessing core application data. We do this via a custom Django middleware that intercepts requests.</p>
<pre><code class="language-python"># users/middleware.py
from django.http import JsonResponse
from django.urls import reverse

class OnboardingMiddleware: 
    def init(self, get_response): 
        self.get_response = get_response
    def call(self, request): 
        # Exempt authentication and onboarding endpoints 
        exempt_urls = [reverse('token_obtain_pair'), '/api/onboard/submit/', '/api/register/']
        if request.path in exempt_urls or request.path.startswith('/admin/'):
            return self.get_response(request)

        user = request.user
        # Block access if authenticated but not onboarded
        if user.is_authenticated and not user.is_onboarded:
            return JsonResponse({
                "error": "ONBOARDING_REQUIRED",
                "message": "Please complete onboarding before accessing this resource."
            }, status=403)
        return self.get_response(request)
</code></pre>
<p>Now, even if a user tries to bypass the frontend routing, the API will refuse to serve them data!</p>
<h3><strong>Refreshing Tokens upon Onboarding</strong></h3>
<p>When the user submits the onboarding form, their status changes in the database. However, their physical JWT won't update automatically. We handle this by issuing completely new tokens in the onboarding response:</p>
<pre><code class="language-python"># users/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, permissions

class OnboardSubmitView(APIView):
    permission_classes = (permissions.IsAuthenticated,)

    def post(self, request):
        user = request.user
        if user.is_onboarded:
            return Response({"detail": "Already onboarded."}, status=400)
        
        user.is_onboarded = True
        user.save()

        # Generate fresh tokens reflecting the new state
        refresh = MyTokenObtainPairSerializer.get_token(user)
        
        return Response({
            "detail": "Onboarding complete.", 
            "is_onboarded": True,
            "access": str(refresh.access_token),
            "refresh": str(refresh)
        }, status=status.HTTP_200_OK)
</code></pre>
<h2><strong>2. Setting up the React Frontend</strong></h2>
<p>We spun up the frontend using React Router v7's built-in Vite template.</p>
<h3><strong>Bypassing CORS locally</strong></h3>
<p>To avoid CORS issues during development, we configured Vite to proxy <code>/api</code> requests to our Django dev server:</p>
<pre><code class="language-javascript">// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  // plugins...
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      }
    }
  }
});
</code></pre>
<h3><strong>Global Authentication Context</strong></h3>
<p>We use React Context to provide user data (<code>token</code>, <code>user</code> payload, <code>login</code>, <code>logout</code>) globally.</p>
<p>A critical detail: <strong>Server-Side Rendering (SSR)</strong>. React Router v7 utilizes SSR by default when hydrating the app. If you try to read <code>localStorage.getItem('access_token')</code> immediately on the server, Node.js will crash with a <code>ReferenceError: localStorage is not defined</code>.</p>
<p>We fix this by waiting for hydration on the client using a simple <code>useEffect</code>:</p>
<pre><code class="language-javascript">// app/contexts/AuthContext.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';

// ... interfaces and parseJwt helper ...

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [token, setToken] = useState&lt;string | null&gt;(null);
  const [isClient, setIsClient] = useState(false);

  // Safe SSR hydration
  useEffect(() =&gt; {
    setIsClient(true);
    setToken(typeof window !== "undefined" ? localStorage.getItem('access_token') : null);
  }, []);

  const user = token ? parseJwt(token) as UserPayload : null;

  useEffect(() =&gt; {
    if (token) localStorage.setItem('access_token', token);
    else localStorage.removeItem('access_token');
  }, [token]);

  const login = (access: string, refresh: string) =&gt; {
    localStorage.setItem('refresh_token', refresh);
    setToken(access);
  };

  if (!isClient) return null; // Prevent hydration mismatch

  return (
    &lt;AuthContext.Provider value={{ token, user, login, logout, refreshAccessToken }}&gt;
      {children}
    &lt;/AuthContext.Provider&gt;
  );
}
</code></pre>
<h3><strong>Route Protection and Redirection</strong></h3>
<p>With our context in place, protecting routes and enforcing onboarding becomes incredibly simple. We intercept users in a <code>useEffect</code> on our page components.</p>
<p>Here is what our protected <strong>Home Dashboard</strong> looks like:</p>
<pre><code class="language-javascript">// app/routes/home.tsx
import { useEffect } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "../contexts/AuthContext";

export default function Home() {
  const { token, user, logout } = useAuth();
  const navigate = useNavigate();

  useEffect(() =&gt; {
    if (!token) {
      navigate("/login");               // Kick out unauthenticated users
    } else if (user &amp;&amp; !user.is_onboarded) {
      navigate("/onboarding");          // Kick out un-onboarded users
    }
  }, [token, user, navigate]);

  // Don't render until verified to prevent brief flashes of content
  if (!user || !user.is_onboarded) return null;

  return (
    &lt;div&gt;
      &lt;h1&gt;Welcome to your portal, {user.username}!&lt;/h1&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>When the user is pushed to <code>/onboarding</code>, they hit our submit button. The API returns the <em>new</em> tokens, we update the context, and React Router instantly pushes them back to <code>/</code> seamlessly!</p>
<pre><code class="language-javascript">// Inside Onboarding component submit handler
const handleComplete = async () =&gt; {
  const res = await fetch("/api/onboard/submit/", {
    method: "POST",
    headers: { "Authorization": `Bearer ${token}` }
  });
  if (res.ok) {
    const data = await res.json();
    login(data.access, data.refresh); // Instantly updates global context
    navigate("/");                    // Redirect to dashboard
  }
};
</code></pre>
<h2><strong>Conclusion</strong></h2>
<p>By embedding the <code>is_onboarded</code> claim directly inside the JWT, we eliminate the need for the frontend to constantly ping the database to check a user's status. Coupling this tightly with Django Middleware ensures backend security, while utilizing React Context provides snappy, seamless redirects on the frontend.</p>
<p>Building auth doesn't have to be painful—with the right architecture, it can be safe, scalable, and a great experience for the end user.</p>
]]></content:encoded></item><item><title><![CDATA[Why I Built My Own Unlimited Audio & Video Transcriber]]></title><description><![CDATA[We've all been there. You have a massive audio file—a two-hour interview, a long lecture, or a recorded meeting—and you just need it converted to text.
You do a quick search for "free audio to text tr]]></description><link>https://blog.vicentereyes.org/why-i-built-my-own-unlimited-audio-video-transcriber</link><guid isPermaLink="true">https://blog.vicentereyes.org/why-i-built-my-own-unlimited-audio-video-transcriber</guid><category><![CDATA[Python]]></category><category><![CDATA[AI]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Mon, 16 Mar 2026 03:42:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f95116240346172a86c20c6/3612a3ac-7567-4e18-846a-f94f21518853.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We've all been there. You have a massive audio file—a two-hour interview, a long lecture, or a recorded meeting—and you just need it converted to text.</p>
<p>You do a quick search for "free audio to text transcriber" and find dozens of online tools. But there's a catch. Once you upload your file, you're hit with a paywall: <em>"File size exceeds limit"</em> or <em>"Upgrade to pro to transcribe more than 60 minutes."</em></p>
<p>I recently ran into this exact problem. I needed a reliable video and audio transcriber that didn't complain when I threw a massive, multi-hour file at it. After hitting limits on almost every online service, I realized the best solution wasn't to pay for a subscription I'd rarely use—it was to build it myself.</p>
<p>Here is how I built my own unlimited, completely free, and private transcriber using Python, OpenAI's Whisper, and Streamlit, accelerated by NVIDIA's CUDA.</p>
<hr />
<h2><strong>The Tech Stack</strong></h2>
<p>I wanted the tool to be simple to use, run locally (so my data stays private), and handle both audio and video files flawlessly. Looking at my <code>requirements.txt</code>, here are the core tools making it possible:</p>
<ol>
<li><p><strong>Python:</strong> The backbone of the project.</p>
</li>
<li><p><strong>Streamlit (v1.55):</strong> A fantastic library that turns Python scripts into interactive web apps in minutes. No HTML/CSS needed.</p>
</li>
<li><p><strong>OpenAI's Whisper:</strong> An incredibly powerful and accurate open-source speech recognition model that runs locally.</p>
</li>
<li><p><strong>PyTorch &amp; NVIDIA CUDA (cu12):</strong> To make the transcription lightning-fast, I included <code>torch</code> along with NVIDIA dependencies like <code>nvidia-cudnn-cu12</code> and <code>nvidia-cublas-cu12</code>. This allows Whisper to harness GPU acceleration, slashing transcription times down from hours to mere minutes.</p>
</li>
<li><p><strong>MoviePy (v2.2):</strong> To handle video files seamlessly by extracting the audio tracks before passing them to Whisper.</p>
</li>
<li><p><strong>python-docx &amp; fpdf2:</strong> To allow exporting the final transcriptions into easily shareable Word documents and PDFs.</p>
</li>
</ol>
<h2><strong>How It Works</strong></h2>
<p>The magic of the application lies in its simplicity. Here is the life cycle of a transcription task in the app:</p>
<h3><strong>1. Handling the Uploads</strong></h3>
<p>Using Streamlit's <code>file_uploader</code>, I built a quick UI that accepts various formats. Streamlit makes this incredibly easy with surprisingly few lines of code:</p>
<pre><code class="language-python">st.title("🎙️ Audio &amp; Video Transcriber")
st.write("Upload a file to convert speech to text.")

uploaded_file = st.file_uploader(
    "Choose a file",
    type=["mp3", "wav", "m4a", "mp4", "mov", "avi"]
)
</code></pre>
<h3><strong>2. Extracting Audio from Video</strong></h3>
<p>Whisper transcribes audio. But often, the content we want to transcribe is locked inside a video format (like a recorded Zoom meeting). To solve this, I integrated <strong>MoviePy</strong>. If the user uploads a video file, the app intercepts it, extracts the audio track in the background, and forwards <em>that</em> to the AI model:</p>
<pre><code class="language-python"># If it's a video, extract the audio using MoviePy 2.0 syntax
if suffix.lower() in [".mp4", ".mov", ".avi"]:
    st.text("Processing video... extracting audio.")
    video = VideoFileClip(input_path)
    audio_path = input_path.replace(suffix, ".mp3")

    # Access audio and write it to a temp file
    video.audio.write_audiofile(audio_path, logger=None)
    processing_path = audio_path
    video.close() # Important to free up system resources
</code></pre>
<h3><strong>3. GPU-Accelerated Transcription</strong></h3>
<p>The heavy lifting is done by <strong>Whisper</strong>. I opted to use the <code>base</code> model, which provides a fantastic balance between transcription speed and accuracy.</p>
<p>Because Whisper runs locally on my machine utilizing PyTorch and my NVIDIA GPU via CUDA, <strong>there are no time limits</strong>. I can feed it a 3-hour podcast, and it will methodically transcribe the entire thing without ever asking me for a credit card.</p>
<p>To make the app snappy and avoid reloading massive AI weights on every action, I used Streamlit's <code>@st.cache_resource</code> decorator:</p>
<pre><code class="language-python"># Initialize Whisper model, caching it to avoid reloading
@st.cache_resource
def load_model():
    # Use "base" for a good balance of speed and accuracy
    return whisper.load_model("base")

model = load_model()

def transcribe_file(file_path):
    st.info("Transcribing... This may take a few minutes.")
    result = model.transcribe(file_path)
    return result['text']
</code></pre>
<h3><strong>4. Exporting the Results</strong></h3>
<p>A block of text on a web page is great, but a downloadable file is better. I used <code>python-docx</code> to generate Word documents formatting the output nicely. Once the transcription is done, the app provides shiny download buttons right there in the UI:</p>
<pre><code class="language-python">def save_as_docx(text, filename):
    doc = Document()
    doc.add_heading('Transcription', 0)
    doc.add_paragraph(text)
    doc.save(filename)
    return filename

# In the Streamlit UI later...
docx_file = "transcription.docx"
save_as_docx(transcript, docx_file)
with open(docx_file, "rb") as f:
    col1.download_button("Download as DOCX", f, file_name="transcription.docx")
</code></pre>
<h2><strong>The Result</strong></h2>
<p>In less than 100 lines of Python code, I built a fully functional, local web app that solves a very real, very annoying problem.</p>
<p><strong>No file size limits. No hidden paywalls. Total privacy. Blazing fast GPU inference.</strong></p>
<p>Building your own tools to solve personal bottlenecks is one of the most rewarding parts of knowing how to code. If you find yourself fighting against the limitations of "free" online software, take a step back and ask yourself: <em>"Could I just build this?"</em></p>
<p>Chances are, with modern libraries and a spare afternoon, you entirely can.</p>
<p>Code can be found here: <a href="https://github.com/reyesvicente/audio-to-text">https://github.com/reyesvicente/audio-to-text</a></p>
]]></content:encoded></item><item><title><![CDATA[A Reimagined Classic: DrugLord '98 in the Browser]]></title><description><![CDATA[If you were gaming in the late 90s and early 2000s, you likely remember the thrill of text-based trading simulation games. Today, we're taking a look at a fantastic modernization of one of those class]]></description><link>https://blog.vicentereyes.org/a-reimagined-classic-druglord-98-in-the-browser</link><guid isPermaLink="true">https://blog.vicentereyes.org/a-reimagined-classic-druglord-98-in-the-browser</guid><category><![CDATA[Web Development]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Fri, 06 Mar 2026 13:35:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f95116240346172a86c20c6/14411e52-09ad-4163-a6e9-912c569725da.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you were gaming in the late 90s and early 2000s, you likely remember the thrill of text-based trading simulation games. Today, we're taking a look at a fantastic modernization of one of those classics: a brand-new, browser-based <strong>DrugLord</strong> reimagining set in the gritty streets of Metro Manila, circa 1998.</p>
<p>Built with a sleek, glowing-green terminal aesthetic that screams "hacker culture," this new iteration brings the addictive "buy low, sell high" gameplay loop into the modern era using Next.js, React, and Tailwind CSS. Let's dive into what makes this browser adaptation so relentlessly playable.</p>
<h2><strong>The Vibe: Neon Green on Pitch Black</strong></h2>
<p>The moment you initialize your connection by entering your alias (Heisenberg, anyone?), you're greeted with a UI that perfectly captures the nostalgia of MS-DOS prompts and old-school BBS interfaces. The glowing green text (<code>text-green-500</code> for the Tailwind fans), monospaced fonts, and clean, modular layout make the game feel instantly familiar yet highly polished.</p>
<p>It doesn’t rely on flashy 3D graphics; instead, it uses a sharp, text-heavy dashboard enriched by beautifully minimal icons from <code>lucide-react</code>. The real tension comes from the numbers ticking up—and plummeting down—on your screen.</p>
<h2><strong>Gameplay Mechanics: Survive and Thrive</strong></h2>
<p>The premise remains faithfully brutal. You have <strong>30 days</strong> to make as much money as possible while dodging the cops, dealing with shady characters, and paying off a massive debt to a relentless Loan Shark.</p>
<p>Here are the core features that drive the simulation:</p>
<h3><strong>🎭 The Metro Manila Market</strong></h3>
<p>You travel across infamous Metro Manila locations like Tondo, Maharlika (Taguig), Tramo, Caloocan, Makati, and Quezon City. Each time you hop on a jeepney or the MRT, a day passes, and the market shifts. Prices for commodities—ranging from Weed to Methamphetamine and high-stakes Cocaine—fluctuate wildly. You might encounter a market crash flooding the streets with cheap product, or a massive police bust that skyrockets prices, giving you the perfect opportunity to offload your stash.</p>
<h3><strong>💼 Inventory &amp; Risk Management</strong></h3>
<p>Your inventory isn't a magical bottomless bag; it's a <strong>trenchcoat</strong>. Space is severely limited, meaning you have to think critically about whether to buy cheap, bulky items or invest in expensive, space-efficient commodities. Occasionally, random events might bless you with a shady character offering a trenchcoat upgrade—for a steep price, of course.</p>
<h3><strong>🏦 Financial Strategy</strong></h3>
<p>Money management is the true heart of the game:</p>
<ul>
<li><p><strong>The Loan Shark:</strong> You start in a deep $5,500 hole with a punishing <strong>10% daily interest rate</strong>. Paying this off early is crucial, or the compounding interest will bury you.</p>
</li>
<li><p><strong>First National Bank:</strong> Don't want to carry all your cash around the dangerous subways? Stash it in the bank. It earns a safe <strong>5% daily interest</strong>, turning the late game into a pure financial optimization puzzle.</p>
</li>
</ul>
<h3><strong>🚔 Random Encounters</strong></h3>
<p>The streets of '98 Metro Manila are unpredictable. A 15% chance of a cop chase keeps you on your toes forcing you to decide whether to <em>Run</em> (risking a fine and damage) or <em>Fight</em> (risking massive health loss). Muggings can suddenly wipe out your hard-earned cash, highlighting the importance of utilizing the bank.</p>
<h2><strong>Under the Hood: The Modern Tech Stack</strong></h2>
<p>What makes this iteration so impressive is how it leverages modern web technologies to deliver a flawless, client-side experience.</p>
<ul>
<li><p><strong>Framework:</strong> The app is powered by <strong>Next.js 15</strong> and <strong>React 19</strong>, giving it lightning-fast state updates and a component-driven architecture.</p>
</li>
<li><p><strong>Styling:</strong> <strong>Tailwind CSS</strong> handles the retro aesthetic, utilizing custom glow shadows (<code>drop-shadow-[0_0_10px_rgba(34,197,94,0.8)]</code>) and animated pulses to bring the terminal to life.</p>
</li>
<li><p><strong>State Persistence:</strong> Custom React hooks (<code>useGameState</code>) tightly couple the game logic with the browser's <code>localStorage</code>, ensuring you never lose your progress if you accidentally close the tab.</p>
</li>
<li><p><strong>AI Integration readiness:</strong> While the core loop is deterministic, the project ships with the <code>@google/genai</code> SDK, hinting at potential future updates featuring LLM-driven complex events or dynamic NPC dialogue.</p>
</li>
</ul>
<h2><strong>Final Verdict</strong></h2>
<p>This browser-based reimagining proves that great game loops don't age. By stripping away modern excesses and leaning hard into a highly optimized, stylized dashboard, this version of <strong>DrugLord</strong> delivers pure, unadulterated dopamine. The tension of holding onto a stash while waiting for a price spike, only to get mugged on the jeepney to Malabon, is as thrilling as ever.</p>
<p>If you have a spare 15 minutes, step into the terminal, watch the market, and see if you can beat the high score. Just remember to pay the Loan Shark first.</p>
<p>Check it out at <a href="https://drugs.vicentereyes.org">https://drugs.vicentereyes.org</a></p>
]]></content:encoded></item><item><title><![CDATA[Problem 15: Longest Common Prefix]]></title><description><![CDATA[Hey everyone! 👋
Today, we're solving a popular string manipulation problem: Longest Common Prefix.
The Problem
The goal is to write a function that finds the longest common prefix among a list of str]]></description><link>https://blog.vicentereyes.org/problem-15-longest-common-prefix</link><guid isPermaLink="true">https://blog.vicentereyes.org/problem-15-longest-common-prefix</guid><category><![CDATA[Python]]></category><category><![CDATA[Beginner Developers]]></category><category><![CDATA[learning]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Mon, 23 Feb 2026 10:55:06 GMT</pubDate><enclosure url="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/5f95116240346172a86c20c6/52536222-3251-4247-85d0-a1def442c8c2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Hey everyone! 👋</p>
<p>Today, we're solving a popular string manipulation problem: <strong>Longest Common Prefix</strong>.</p>
<h2><strong>The Problem</strong></h2>
<p>The goal is to write a function that finds the longest common prefix among a list of strings.</p>
<ul>
<li><p>A <strong>prefix</strong> is a substring that occurs at the beginning of a string.</p>
</li>
<li><p>The function should return the longest common string that all words in the list start with.</p>
</li>
<li><p>If there is no common prefix, it should return an empty string <code>""</code>.</p>
</li>
</ul>
<p><strong>Examples:</strong></p>
<ul>
<li><p><code>longest_common_prefix(['flower', 'flow', 'flight'])</code> → Should return <code>'fl'</code></p>
</li>
<li><p><code>longest_common_prefix(['dog', 'racecar', 'car'])</code> → Should return <code>""</code> (no common prefix)</p>
</li>
<li><p><code>longest_common_prefix(['interspecies', 'interstellar'])</code> → Should return <code>'inters'</code></p>
</li>
</ul>
<h2><strong>The Solution</strong></h2>
<p>Here is the Python implementation:</p>
<pre><code class="language-python">def longest_common_prefix(strings):
    """
    Finds the longest common prefix among a list of strings.
    """
    # Check for empty or invalid input
    if not strings:
        return ""

    # Set the first string as the initial prefix
    prefix = strings[0]

    # Iterate through the remaining strings
    for s in strings[1:]:
        # Shorten the prefix until it matches the start of the current string
        while not s.startswith(prefix):
            prefix = prefix[:-1]
            # If the prefix becomes empty, there is no common prefix
            if prefix == "":
                return ""

    return prefix

# Test cases
print(longest_common_prefix(['flower', 'flow', 'flight']))      # 'fl'
print(longest_common_prefix(['dog', 'racecar', 'car']))         # ''
print(longest_common_prefix(['interspecies', 'interstellar']))  # 'inters'
</code></pre>
<h2><strong>Code Breakdown</strong></h2>
<p>Let's walk through the code line by line:</p>
<ol>
<li><p><code>def longest_common_prefix(strings):</code></p>
<ul>
<li>Defines a function named <code>longest_common_prefix</code> that takes a list of words (<code>strings</code>) as input.</li>
</ul>
</li>
<li><p><code>if not strings:</code></p>
<ul>
<li><p>Checks if the input list is empty or <code>None</code>.</p>
</li>
<li><p>If it is empty, we return an empty string <code>""</code>.</p>
</li>
<li><p><em>Note: This is a safer and more Pythonic check than</em> <code>strings is None or strings == 0</code> <em>limit cases!</em></p>
</li>
</ul>
</li>
<li><p><code>prefix = strings[0]</code></p>
<ul>
<li>Sets our initial assumption: let's assume the entire first string is the common prefix.</li>
</ul>
</li>
<li><p><code>for s in strings[1:]:</code></p>
<ul>
<li><p>Iterates through each subsequent string (<code>s</code>) in the list.</p>
</li>
<li><p><code>strings[1:]</code> is Python slice notation that means "all elements from index 1 to the end".</p>
</li>
</ul>
</li>
<li><p><code>while not s.startswith(prefix):</code></p>
<ul>
<li><p>Keeps running as long as the current string <code>s</code> doesn't start with our assumed <code>prefix</code>.</p>
</li>
<li><p><code>.startswith()</code> checks if a string begins with the specified prefix.</p>
</li>
</ul>
</li>
<li><p><code>prefix = prefix[:-1]</code></p>
<ul>
<li><p>Removes the last character from the prefix to make it shorter.</p>
</li>
<li><p><code>prefix[:-1]</code> is string slicing that takes all characters except the last one.</p>
</li>
</ul>
</li>
<li><p><code>if prefix == "":</code></p>
<ul>
<li><p>If we've removed all characters from the prefix, it means the current word shares no common letters at the beginning with our initial assumed prefix.</p>
</li>
<li><p>We immediately return <code>""</code> as we know there is no common sequence.</p>
</li>
</ul>
</li>
<li><p><code>return prefix</code></p>
<ul>
<li>Once we've successfully compared our prefix against all strings, the remaining shortened string is safely our longest common prefix!</li>
</ul>
</li>
</ol>
<h2><strong>Example Walkthrough</strong></h2>
<p>Let's trace the function with <code>longest_common_prefix(['flower', 'flow', 'flight'])</code>:</p>
<ol>
<li><p><strong>Initialization:</strong></p>
<ul>
<li>Start with <code>prefix = 'flower'</code></li>
</ul>
</li>
<li><p><strong>Compare with</strong> <code>'flow'</code> <strong>(First iteration):</strong></p>
<ul>
<li><p><code>'flow'</code> doesn't start with <code>'flower'</code>? True.</p>
</li>
<li><p>Remove last char: <code>prefix = 'flowe'</code></p>
</li>
<li><p><code>'flow'</code> doesn't start with <code>'flowe'</code>? True.</p>
</li>
<li><p>Remove last char: <code>prefix = 'flow'</code></p>
</li>
<li><p><code>'flow'</code> doesn't start with <code>'flow'</code>? False. Stop while loop.</p>
</li>
<li><p>Current prefix is safely <code>'flow'</code>.</p>
</li>
</ul>
</li>
<li><p><strong>Compare with</strong> <code>'flight'</code> <strong>(Second iteration):</strong></p>
<ul>
<li><p><code>'flight'</code> doesn't start with <code>'flow'</code>? True.</p>
</li>
<li><p>Remove last char: <code>prefix = 'flo'</code></p>
</li>
<li><p><code>'flight'</code> doesn't start with <code>'flo'</code>? True.</p>
</li>
<li><p>Remove last char: <code>prefix = 'fl'</code></p>
</li>
<li><p><code>'flight'</code> doesn't start with <code>'fl'</code>? False. Stop while loop.</p>
</li>
<li><p>Current prefix is safely <code>'fl'</code>.</p>
</li>
</ul>
</li>
<li><p><strong>Result:</strong> Loop completes, return <code>'fl'</code>. ✅</p>
</li>
</ol>
<p>Now let's try <code>longest_common_prefix(['dog', 'racecar', 'car'])</code>:</p>
<ol>
<li><p><strong>Initialization:</strong></p>
<ul>
<li>Start with <code>prefix = 'dog'</code></li>
</ul>
</li>
<li><p><strong>Compare with</strong> <code>'racecar'</code><strong>:</strong></p>
<ul>
<li><p><code>'racecar'</code> doesn't start with <code>'dog'</code>? True. Shorten to <code>prefix = 'do'</code></p>
</li>
<li><p><code>'racecar'</code> doesn't start with <code>'do'</code>? True. Shorten to <code>prefix = 'd'</code></p>
</li>
<li><p><code>'racecar'</code> doesn't start with <code>'d'</code>? True. Shorten to <code>prefix = ""</code></p>
</li>
</ul>
</li>
<li><p><strong>Result:</strong> Prefix is empty <code>""</code>. Immediate return <code>""</code>. ❌ No common prefix found.</p>
</li>
</ol>
<h2><strong>Key Optimizations</strong></h2>
<p>This approach relies heavily on <strong>Horizontal Matching</strong>:</p>
<ul>
<li><p><strong>Shrinking Search Pattern:</strong> Instead of checking letter by letter precisely (vertical scaling index-by-index), we start by assuming the maximum possible prefix and aggressively peel off letters from the end whenever mismatches are found.</p>
</li>
<li><p><strong>Early Escape:</strong> If the prefix shrinks to an empty string at any point, we completely abort without iterating through the rest of the array strings.</p>
</li>
</ul>
<p>This pattern is highly effective, easy to read, and simple to reason out! 🚀</p>
<hr />
<p>Happy coding! 💻</p>
]]></content:encoded></item><item><title><![CDATA[How I Fixed the Hashnode GraphQL API Stale Cache Bug (Stellate CDN)]]></title><description><![CDATA[If you're using Hashnode's GraphQL API to fetch your blog posts for a custom frontend (like a React or Next.js portfolio), you've probably run into this incredibly frustrating issue: You publish a new]]></description><link>https://blog.vicentereyes.org/how-i-fixed-the-hashnode-graphql-api-stale-cache-bug-stellate-cdn</link><guid isPermaLink="true">https://blog.vicentereyes.org/how-i-fixed-the-hashnode-graphql-api-stale-cache-bug-stellate-cdn</guid><category><![CDATA[GraphQL]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[debugging]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Mon, 23 Feb 2026 05:27:26 GMT</pubDate><enclosure url="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/5f95116240346172a86c20c6/9c8b547a-834d-4b48-9b0c-39f3ca8dd82a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you're using Hashnode's GraphQL API to fetch your blog posts for a custom frontend (like a React or Next.js portfolio), you've probably run into this incredibly frustrating issue: <strong>You publish a new post on Hashnode, but it doesn't show up on your website.</strong></p>
<p>You check the API payload, and it's serving a stale list of posts. The new post is completely missing.</p>
<p>I spent hours debugging this, trying every cache-busting trick in the book. Here's what didn't work, why it failed, and the actual simple solution.</p>
<h2><strong>The Setup</strong></h2>
<p>My portfolio runs on React (Vite) and uses Hashnode as a headless CMS. I fetch posts using <code>fetch</code> with the official Hashnode GraphQL endpoint: <code>https://gql.hashnode.com</code>.</p>
<pre><code class="language-typescript">const res = await fetch('https://gql.hashnode.com', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query: POSTS_QUERY, variables }),
});
</code></pre>
<p>When I added a new post, my local dev server still only showed the old posts.</p>
<h2><strong>The Failed Attempts</strong></h2>
<p>Hashnode uses <strong>Stellate</strong>, a powerful GraphQL Edge CDN. Stellate sits between your frontend and Hashnode's database, caching responses to make the API blazing fast. However, its caching mechanism is exceptionally aggressive.</p>
<p>Here are the standard tricks I tried to bypass the cache, all of which <strong>failed</strong>:</p>
<ol>
<li><p><strong>Setting standard HTTP headers:</strong> Adding <code>Cache-Control: no-cache</code> and <code>Pragma: no-cache</code> to the <code>fetch</code> call. Stellate ignores these from the client.</p>
</li>
<li><p><strong>URL Query Params:</strong> Appending a timestamp <code>?_t=${Date.now()}</code> to the endpoint URL. Since this is a <code>POST</code> request, Stellate keys the cache off the request body, not just the URL.</p>
</li>
<li><p><strong>GraphQL</strong> <code>extensions</code><strong>:</strong> Adding <code>extensions: { cacheKey: Date.now() }</code> to the JSON body. Stellate normalization strips this out.</p>
</li>
<li><p><strong>Unknown GraphQL Variables:</strong> Injecting <code>_cacheBust: Date.now()</code> into the <code>variables</code> object. Stellate strips unknown variables.</p>
</li>
<li><p><strong>GraphQL Comments:</strong> Injecting <code># ${Date.now()}</code> directly into the query string. Stellate parses and normalizes the query string, stripping comments before hashing the cache key.</p>
</li>
</ol>
<p>No matter what I did, inspecting the network tab always revealed the same mocking response header: <code>gcdn-cache: HIT</code>.</p>
<h2><strong>The Real Issue: Stellate Needs the</strong> <code>id</code> <strong>Field</strong></h2>
<p>The root cause isn't that Stellate is ignoring your cache-busting hacks; it's that <strong>Stellate doesn't know the data is stale.</strong></p>
<p>Stellate automatically invalidates its cache when backend data changes (mutations). But to do this effectively across complex GraphQL graphs, it relies on a core concept: <strong>tracking Node IDs</strong>.</p>
<p>If your query doesn't ask for the <code>id</code> of the entities it's fetching, Stellate cannot trace the cached data back to the actual database records. When Hashnode updates its database, Stellate's purge mechanism fires, but if your cached query didn't include IDs, Stellate doesn't know to invalidate <em>that specific query</em>.</p>
<p>This is a well-known issue internally at Hashnode — they even created an ESLint plugin (<code>require-id-when-available</code>) for their own engineers to prevent it!</p>
<h2><strong>The Solution</strong></h2>
<p>The fix is almost disappointingly simple. <strong>You must include the</strong> <code>id</code> <strong>field in all your GraphQL queries and fragments related to Hashnode.</strong></p>
<p>My original query looked like this:</p>
<pre><code class="language-graphql">query Publication(\(host: String!, \)first: Int!) {
  publication(host: $host) {
    posts(first: $first) {
      edges {
        node {
          slug
          title
          brief
          publishedAt
        }
      }
    }
  }
}
</code></pre>
<p>The fix is simply adding <code>id</code> inside the <code>node</code>:</p>
<pre><code class="language-graphql">query Publication(\(host: String!, \)first: Int!) {
  publication(host: $host) {
    id  # &lt;-- ESSENTIAL FOR LIST INVALIDATION
    posts(first: $first) {
      edges {
        node {
          id  # &lt;-- ESSENTIAL FOR ENTITY INVALIDATION
          slug
          title
          brief
          publishedAt
        }
      }
    }
  }
}
</code></pre>
<p>Make sure to add <code>id</code> to everywhere you query entities: the parent <code>publication</code> object, <code>posts</code>, <code>post</code>, <code>seriesList</code>, etc.</p>
<p><strong>Why both?</strong></p>
<ul>
<li><p>Adding <code>id</code> to the <code>node</code> tells Stellate to update the cache for that specific post if it gets edited.</p>
</li>
<li><p>Adding <code>id</code> to the parent <code>publication</code> tells Stellate to invalidate the <em>entire list</em> of posts for that publication when a new post is published or deleted.</p>
</li>
</ul>
<p>Once I updated my queries and refreshed, the CDN properly registered the unique records and lists. New posts now invalidate the cache automatically, and the API returns fresh data immediately upon publishing.</p>
<p><strong>Takeaway:</strong> When working with GraphQL APIs behind Edge CDNs like Stellate, always fetch the <code>id</code>. It's not just good practice; it's the anchor for their entire caching strategy.</p>
]]></content:encoded></item><item><title><![CDATA[Tracking Page Views in a React SPA with Google Analytics 4]]></title><description><![CDATA[Adding Google Analytics (GA4) to a standard HTML website is straightforward: paste the tracking snippet into your <head> and you're done. Every time a user clicks a link, the browser fetches a new HTM]]></description><link>https://blog.vicentereyes.org/tracking-page-views-in-a-react-spa-with-google-analytics-4</link><guid isPermaLink="true">https://blog.vicentereyes.org/tracking-page-views-in-a-react-spa-with-google-analytics-4</guid><category><![CDATA[React]]></category><category><![CDATA[analytics]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Mon, 23 Feb 2026 02:30:00 GMT</pubDate><enclosure url="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/5f95116240346172a86c20c6/286fc2ef-48ef-4882-9863-c4277636fb3e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Adding Google Analytics (GA4) to a standard HTML website is straightforward: paste the tracking snippet into your <code>&lt;head&gt;</code> and you're done. Every time a user clicks a link, the browser fetches a new HTML page, and GA registers a page view.</p>
<p>But if you are building a Single Page Application (SPA) with React, Vite, and React Router, this out-of-the-box behavior breaks down.</p>
<p>In a React SPA, clicking a link doesn't trigger a page reload. React simply unmounts the old component and mounts the new one while manipulating the browser's URL history. Because the page never actually "reloads," Google Analytics never registers the new URL, and your analytics will show users seemingly stuck on the homepage forever.</p>
<p>Here is exactly how I solved this for my portfolio site.</p>
<hr />
<h2><strong>1. The Environment Setup</strong></h2>
<p>First, avoid hardcoding your tracking ID into your source code. If your repo is public, anyone could scrape it. Add your Measurement ID (found in your GA dashboard, usually starting with <code>G-XXXXXXXXXX</code>) to your <code>.env</code> file.</p>
<pre><code class="language-env">VITE_GA_TRACKING_ID=G-**********
</code></pre>
<h2><strong>2. The Initial HTML Snippet (Modified)</strong></h2>
<p>You still need the base Google Analytics tracking code in your <code>index.html</code>.</p>
<p>However, we need to make one critical modification: <strong>we must tell GA4 <em>not</em> to automatically track the initial page view.</strong> If we don't disable it, we will end up double-counting the first page load when our React Router listener kicks in later.</p>
<p>In <code>index.html</code>:</p>
<pre><code class="language-html">  &lt;!-- Google tag (gtag.js) --&gt;
  &lt;script async src="https://www.googletagmanager.com/gtag/js?id=%VITE_GA_TRACKING_ID%"&gt;&lt;/script&gt;
  &lt;script&gt;
    window.dataLayer = window.dataLayer || [];
    function gtag() { dataLayer.push(arguments); }
    gtag('js', new Date());

    // IMPORTANT: Disable the default page_view tracking here!
    gtag('config', '%VITE_GA_TRACKING_ID%', { send_page_view: false });
  &lt;/script&gt;
</code></pre>
<blockquote>
<p><strong>Note on Vite:</strong> I used <code>%VITE_GA_TRACKING_ID%</code> to inject the environment variable directly into the HTML at build time.</p>
</blockquote>
<h2><strong>3. Creating the Route Listener Component</strong></h2>
<p>Now we need a way to listen to URL changes inside React and ping Google Analytics manually. We can create a lightweight, invisible component to handle this using the <code>useLocation</code> hook from <code>react-router-dom</code>.</p>
<p>Create <code>components/Analytics.tsx</code>:</p>
<pre><code class="language-tsx">import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

// Extend window object for TypeScript so it doesn't complain about window.gtag
declare global {
  interface Window {
    gtag: (...args: any[]) =&gt; void;
  }
}

export const Analytics = () =&gt; {
  const location = useLocation();

  useEffect(() =&gt; {
    // Read the tracking ID from environment variables
    const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_TRACKING_ID;

    // Ensure the ID exists and the gtag function was loaded successfully
    if (GA_MEASUREMENT_ID &amp;&amp; typeof window.gtag === 'function') {

      // Ping Google Analytics manually
      window.gtag('config', GA_MEASUREMENT_ID, {
        page_path: location.pathname + location.search,
      });

    }
  }, [location]); // Re-run this effect every time the URL changes

  return null; // This component is invisible
};
</code></pre>
<h2><strong>4. Wiring it up to the Router</strong></h2>
<p>Finally, we need to mount our new <code>&lt;Analytics /&gt;</code> component.</p>
<p>The most important rule here is that <code>Analytics</code> <strong>must be placed inside the</strong> <code>&lt;BrowserRouter&gt;</code> but <em>outside</em> of the <code>&lt;Routes&gt;</code> block. If it's outside the Router, the <code>useLocation</code> hook will throw a contextual error.</p>
<p>In <code>index.tsx</code>:</p>
<pre><code class="language-tsx">import { Analytics } from './components/Analytics';
// ... other imports

const App = () =&gt; {
  return (
    &lt;HelmetProvider&gt;
      &lt;BrowserRouter&gt;

        {/* Place the listener here! */}
        &lt;Analytics /&gt;
        &lt;ScrollToTop /&gt;

        &lt;Suspense fallback={&lt;LoadingFallback /&gt;}&gt;
          &lt;Routes&gt;
            &lt;Route path="/" element={&lt;HomePage /&gt;} /&gt;
            &lt;Route path="/about" element={&lt;AboutPage /&gt;} /&gt;
            {/* ... other routes */}
          &lt;/Routes&gt;
        &lt;/Suspense&gt;

      &lt;/BrowserRouter&gt;
    &lt;/HelmetProvider&gt;
  );
};
</code></pre>
<h2><strong>The Result</strong></h2>
<p>And that's it!</p>
<p>Now, when a user lands on <code>vicentereyes.org</code>, the Google Analytics script loads. Then, React boots up, mounts the router, hits the <code>&lt;Analytics /&gt;</code> component, and fires a page view event for <code>/</code>.</p>
<p>When the user clicks "Projects", React Router handles the transition, the URL updates to <code>/projects</code>, the <code>useEffect</code> inside <code>&lt;Analytics /&gt;</code> fires again, and a clean page view event for <code>/projects</code> is pushed to Google. Perfect SPA tracking, no heavy external libraries required.</p>
]]></content:encoded></item><item><title><![CDATA[Elevating the Portfolio: A Deep Dive into Recent Enhancements]]></title><description><![CDATA[A portfolio is more than just a list of links; it's a narrative of problem-solving, creativity, and technical execution. Recently, significant updates were pushed to the portfolio to refine its presen]]></description><link>https://blog.vicentereyes.org/elevating-the-portfolio-a-deep-dive-into-recent-enhancements</link><guid isPermaLink="true">https://blog.vicentereyes.org/elevating-the-portfolio-a-deep-dive-into-recent-enhancements</guid><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[AI]]></category><category><![CDATA[Artificial Intelligence]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Sat, 21 Feb 2026 00:59:18 GMT</pubDate><enclosure url="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/5f95116240346172a86c20c6/17528948-6a1d-48ee-9ab3-aa52e80db972.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A portfolio is more than just a list of links; it's a narrative of problem-solving, creativity, and technical execution. Recently, significant updates were pushed to the portfolio to refine its presentation, improve the user experience, and ensure it accurately and powerfully reflects a diverse range of skills—from full-stack development to audio engineering.</p>
<p>Here is a breakdown of the key architectural, functional, and design enhancements made during this update cycle.</p>
<h2><strong>1. Revamping the Projects Showcase</strong></h2>
<p>The core of any developer's portfolio is their past work. The goal was to transition from a simple showcase to a comprehensive case-study format.</p>
<ul>
<li><p><strong>Dedicated Project Pages:</strong> We moved away from just listing projects and introduced a dedicated <code>/projects</code> route for browsing, alongside dynamic <code>/projects/:slug</code> pages for deep-dive case studies.</p>
</li>
<li><p><strong>Rich Storytelling &amp; Metadata:</strong> The underlying data structure (<code>constants.ts</code>) was significantly expanded. Instead of just a title and a link, projects now feature detailed <strong>Challenge</strong>, <strong>Solution</strong>, and <strong>Result</strong> sections. This allows visitors to understand not just <em>what</em> was built, but <em>why</em> and <em>how</em>.</p>
</li>
<li><p><strong>A Comprehensive Roster:</strong> We conducted a thorough audit of the project list. We restored several missing projects (such as <em>Fashion Boulevard</em>, <em>Renegade Jewelry</em>, and <em>Limitless Fashion</em>) and meticulously refined the copywriting for over 20 individual projects. Every entry now accurately reflects the specific technologies used, the unique challenges faced, and the tangible business results achieved.</p>
</li>
<li><p><strong>Status Badges:</strong> Projects were clearly categorized with 'Active' (live links) or 'Sunset' statuses, providing immediate context to the visitor.</p>
</li>
</ul>
<h2><strong>2. A Dedicated, Highly Functional Contact Experience</strong></h2>
<p>To facilitate better communication with potential clients and collaborators, the contact flow needed to be both visually striking and technically robust.</p>
<ul>
<li><p><strong>The Neo-Brutalist</strong> <code>/contact</code> <strong>Page:</strong> A brand new, standalone contact page was built from the ground up, strictly adhering to the site's bold, high-contrast Neo-Brutalist design language.</p>
</li>
<li><p><strong>Seamless EmailJS Integration:</strong> The form was wired up using EmailJS. We enhanced the payload structure so that when a message is sent, the sender's Name and Email are clearly prepended to the email body, ensuring essential context is never lost in the inbox.</p>
</li>
<li><p><strong>Engaging UX States:</strong> We replaced default browser behaviors with custom, visually engaging UI states. Upon a successful submission, the form gracefully disappears, replaced by a bold "Message Received!" confirmation screen. Similarly, clear error banners were implemented to guide the user if something goes wrong.</p>
</li>
<li><p><strong>Flawless Dark Mode:</strong> Careful attention was paid to color inheritance, ensuring the form inputs and labels remain highly legible and aesthetically pleasing when a user toggles Dark Mode.</p>
</li>
</ul>
<h2><strong>3. Globalizing "Icen's AI Twin"</strong></h2>
<p>The custom AI Assistant is a standout feature of the site, but its utility was previously limited to the homepage.</p>
<ul>
<li><p><strong>Ubiquitous Access:</strong> We refactored the application's routing structure (<code>index.tsx</code>), elevating the <code>&lt;AIAssistant /&gt;</code> component to the top-level layout.</p>
</li>
<li><p><strong>Always Ready to Help:</strong> Now, the AI chat bubble floats seamlessly across <em>all</em> routes—whether a visitor is browsing a specific project case study, reading the About page, or looking at Services. "Icen's AI Twin" is now a persistent, helpful presence throughout the entire user journey.</p>
</li>
</ul>
<h2><strong>4. Polishing the Details</strong></h2>
<p>Great design is in the details, and several smaller tweaks were made to ensure a cohesive experience:</p>
<ul>
<li><p><strong>Button Consistency:</strong> We added new variants (like a dedicated 'white' variant) to the core <code>&lt;Button /&gt;</code> component to ensure calls-to-action—like the "Next Project" button—look perfect regardless of whether the site is in light or dark mode.</p>
</li>
<li><p><strong>Routing Refinements:</strong> Main navigation links and homepage CTAs were updated to integrate smoothly with the new dedicated routes, eliminating reliance on simple anchor hashes where full pages now exist.</p>
</li>
</ul>
<hr />
<h3><strong>The Result</strong></h3>
<p>These focused updates transform the portfolio from a simple directory into an interactive, narrative-driven platform. It not only showcases technical capability through the projects themselves but also through the polished, performant, and highly detailed execution of the portfolio site itself.</p>
]]></content:encoded></item><item><title><![CDATA[The Great Decoupling: Is Headless WordPress Right for Your Next Project?]]></title><description><![CDATA[In the world of web development, "Headless" has become the architectural gold standard for high-performance applications. But for those of us who have spent years in the comfortable, PHP-driven embrace of traditional WordPress, the jump to a decouple...]]></description><link>https://blog.vicentereyes.org/the-great-decoupling-is-headless-wordpress-right-for-your-next-project</link><guid isPermaLink="true">https://blog.vicentereyes.org/the-great-decoupling-is-headless-wordpress-right-for-your-next-project</guid><category><![CDATA[WordPress]]></category><category><![CDATA[headless]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Tue, 17 Feb 2026 04:40:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771303193900/183ef68a-002f-4892-af32-30310b0fdc86.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the world of web development, "Headless" has become the architectural gold standard for high-performance applications. But for those of us who have spent years in the comfortable, PHP-driven embrace of traditional WordPress, the jump to a decoupled setup is a significant leap.</p>
<p>Is it a performance-boosting revolution or a maintenance nightmare? Let’s break down the pros and cons of moving from a native WordPress site to a headless architecture.</p>
<hr />
<h3 id="heading-what-exactly-is-headless-wordpress">What Exactly is "Headless" WordPress?</h3>
<p>In a <strong>Native (Monolithic)</strong> setup, WordPress is the whole engine and the car body. It handles the database, the admin dashboard, and the "head"—the theme that your visitors see.</p>
<p>In a <strong>Headless (Decoupled)</strong> setup, we chop off the head. WordPress stays in the garage as a backend content management system (CMS). It serves your posts and pages as raw data through an API (REST or WPGraphQL). Your "head" is a completely separate application built with modern tools like <strong>Next.js, React, or Vue</strong>.</p>
<hr />
<h3 id="heading-the-pros-why-go-headless">The Pros: Why Go Headless?</h3>
<h4 id="heading-1-performance-and-core-web-vitals">1. Performance and Core Web Vitals</h4>
<p>Native WordPress themes can become "bloated" with CSS and JavaScript from dozens of plugins. Headless sites typically use <strong>Static Site Generation (SSG)</strong>. Your pages are pre-rendered into lightweight HTML files and served via a Global CDN.</p>
<ul>
<li><strong>The Result:</strong> Instant load times and perfect 100/100 Lighthouse scores.</li>
</ul>
<h4 id="heading-2-future-proofing-with-omnichannel-content">2. Future-Proofing with "Omnichannel" Content</h4>
<p>When your content is just an API endpoint, it isn't trapped on a website. You can pull that same "About Us" text into:</p>
<ul>
<li><p>An iOS or Android mobile app.</p>
</li>
<li><p>A smart watch interface.</p>
</li>
<li><p>Digital signage or kiosks.</p>
</li>
</ul>
<h4 id="heading-3-fortified-security">3. Fortified Security</h4>
<p>Standard WordPress sites are frequent targets for bots. By separating the frontend, you can hide your <code>wp-admin</code> on a private subdirectory or a different server entirely. Hackers can't "brute force" a login page they can't even find.</p>
<h4 id="heading-4-developer-happiness">4. Developer Happiness</h4>
<p>Modern developers often prefer working with <strong>React or TypeScript</strong> over legacy PHP templates. Decoupling allows your team to use the best tools for the job without being restricted by the WordPress "Loop."</p>
<hr />
<h3 id="heading-the-cons-the-hidden-costs-of-freedom">The Cons: The Hidden Costs of Freedom</h3>
<h4 id="heading-1-the-preview-problem">1. The "Preview" Problem</h4>
<p>In native WordPress, you hit "Preview" and see your changes instantly. In headless, the WordPress dashboard doesn't know what your separate frontend looks like. Setting up a live preview requires custom engineering and additional infrastructure.</p>
<h4 id="heading-2-plugin-compatibility-issues">2. Plugin Compatibility Issues</h4>
<p>This is the biggest hurdle. Many popular plugins (like <strong>Gravity Forms, Yoast SEO, or Elementor</strong>) rely on the native theme layer to function. In a headless setup, these won't work out of the box. You'll need to manually fetch that data via the API and rebuild the UI from scratch.</p>
<h4 id="heading-3-increased-complexity-amp-hosting-costs">3. Increased Complexity &amp; Hosting Costs</h4>
<p>You are no longer managing one site; you are managing two separate environments:</p>
<ol>
<li><p><strong>The Backend:</strong> WordPress hosting (e.g., WP Engine, Kinsta).</p>
</li>
<li><p><strong>The Frontend:</strong> JavaScript hosting (e.g., Vercel, Netlify).</p>
</li>
</ol>
<h4 id="heading-4-seo-responsibility">4. SEO Responsibility</h4>
<p>While headless is faster (which helps SEO), you lose the "automatic" benefits of SEO plugins. You must manually handle your meta tags, sitemaps, and schema markup within your JavaScript framework.</p>
<hr />
<h3 id="heading-the-verdict-should-you-make-the-switch">The Verdict: Should You Make the Switch?</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Choose Native WordPress if...</strong></td><td><strong>Choose Headless WordPress if...</strong></td></tr>
</thead>
<tbody>
<tr>
<td>You are a small team or solo blogger.</td><td>You have a dedicated dev team (React/Next.js).</td></tr>
<tr>
<td>You rely heavily on Page Builders.</td><td>You need "App-like" speed and transitions.</td></tr>
<tr>
<td>You have a tight budget and timeline.</td><td>You need to push content to multiple platforms.</td></tr>
<tr>
<td>You want "plug-and-play" plugin features.</td><td>Security and scalability are top priorities.</td></tr>
</tbody>
</table>
</div><h3 id="heading-final-thought">Final Thought</h3>
<p>Moving to headless isn't just a technical upgrade; it’s a change in philosophy. It’s perfect for enterprise-grade projects that need to scale, but for a standard business site, the simplicity of a well-optimized native WordPress theme is often still the smarter play.</p>
]]></content:encoded></item><item><title><![CDATA[To Headless or Not to Headless? A Shopify Expert’s Guide to the Pros and Cons]]></title><description><![CDATA[As a Shopify developer, the most common "crossroads" question I get from growing brands is: "Should we go headless?" With the rise of Hydrogen/Oxygen and frameworks like Next.js, the allure of total creative freedom is stronger than ever. However, "H...]]></description><link>https://blog.vicentereyes.org/to-headless-or-not-to-headless-a-shopify-experts-guide-to-the-pros-and-cons</link><guid isPermaLink="true">https://blog.vicentereyes.org/to-headless-or-not-to-headless-a-shopify-experts-guide-to-the-pros-and-cons</guid><category><![CDATA[React]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[shopify]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Tue, 17 Feb 2026 04:32:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771302711308/a32f8f9a-f8c5-4678-835a-a435e13f45ee.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>As a Shopify developer, the most common "crossroads" question I get from growing brands is: <em>"Should we go headless?"</em> With the rise of <strong>Hydrogen/Oxygen</strong> and frameworks like <strong>Next.js</strong>, the allure of total creative freedom is stronger than ever. However, "Headless Shopify" isn't a magic wand—it’s a powerful architectural shift that comes with its own set of trade-offs.</p>
<p>If you are hitting the "glass ceiling" of Liquid or simply want to know if the investment is worth the ROI, here is the breakdown.</p>
<hr />
<h2 id="heading-the-pros-why-brands-go-headless"><strong>🚀 The Pros: Why Brands Go Headless</strong></h2>
<h3 id="heading-1-unrestricted-ux-amp-performance"><strong>1. Unrestricted UX &amp; Performance</strong></h3>
<p>In a traditional Liquid setup, you are bound by Shopify’s theme engine and fixed URL structures. Headless breaks those walls.</p>
<ul>
<li><p><strong>Near-Instant Speeds:</strong> By leveraging SSR (Server-Side Rendering) or SSG (Static Site Generation), you can achieve perfect Lighthouse scores and instantaneous page transitions.</p>
</li>
<li><p><strong>Complex Interactions:</strong> If your brand requires high-state interfaces—like advanced product configurators or immersive 3D/AR experiences—React-based frameworks handle these much more gracefully than synchronous Liquid rendering.</p>
</li>
</ul>
<h3 id="heading-2-seo-amp-url-flexibility"><strong>2. SEO &amp; URL Flexibility</strong></h3>
<p>This is often the "deciding factor" for large migrations. Native Shopify uses a fixed <code>/products/</code> and <code>/collections/</code> structure. If you need custom URL patterns to maintain legacy SEO rankings or specific site hierarchies, headless is currently the only way to achieve that level of control.</p>
<h3 id="heading-3-best-of-breed-tech-stack"><strong>3. "Best-of-Breed" Tech Stack</strong></h3>
<p>Headless allows you to decouple your content from your commerce. You can pull high-end editorial content from a CMS like <strong>Sanity</strong> or <strong>Contentful</strong> and merge it seamlessly with Shopify’s checkout and product data.</p>
<hr />
<h2 id="heading-the-cons-the-hidden-costs-of-freedom"><strong>⚠️ The Cons: The Hidden Costs of Freedom</strong></h2>
<h3 id="heading-1-the-app-integration-gap"><strong>1. The "App" Integration Gap</strong></h3>
<p>This is the biggest hurdle for most merchants. In Liquid, most Shopify apps "just work" via theme app extensions. In a headless setup, <strong>apps do not work out of the box.</strong> You must manually integrate every service (reviews, loyalty, search) via its API, which significantly increases development time and budget.</p>
<h3 id="heading-2-increased-maintenance-amp-complexity"><strong>2. Increased Maintenance &amp; Complexity</strong></h3>
<p>When you go headless, you are no longer just managing a store; you are managing a software application.</p>
<ul>
<li><p><strong>Infrastructure:</strong> You (or your dev team) are responsible for the frontend hosting (Vercel, Netlify, or Shopify Oxygen).</p>
</li>
<li><p><strong>Developer Dependency:</strong> Even small frontend tweaks usually require a developer. You lose the "drag-and-drop" agility of the Shopify Theme Editor unless you invest heavily in building a custom preview system.</p>
</li>
</ul>
<h3 id="heading-3-higher-initial-investment"><strong>3. Higher Initial Investment</strong></h3>
<p>A custom Liquid theme might take weeks to build, whereas a headless build typically takes months. The initial CAPEX is significantly higher due to the custom engineering required to replicate basic Shopify features.</p>
<hr />
<h2 id="heading-quick-comparison-liquid-vs-headless"><strong>Quick Comparison: Liquid vs. Headless</strong></h2>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Feature</td><td>Traditional Liquid</td><td>Headless (Hydrogen/Next.js)</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Development Speed</strong></td><td>Fast (Weeks)</td><td>Slower (Months)</td></tr>
<tr>
<td><strong>App Compatibility</strong></td><td>Native / Automatic</td><td>API-dependent / Manual</td></tr>
<tr>
<td><strong>Performance</strong></td><td>Good (Optimized)</td><td>Exceptional (Built right)</td></tr>
<tr>
<td><strong>Maintenance</strong></td><td>Low (Shopify-managed)</td><td>High (Custom-managed)</td></tr>
<tr>
<td><strong>Creative Freedom</strong></td><td>Moderate</td><td>Unlimited</td></tr>
</tbody>
</table>
</div><hr />
<h2 id="heading-the-verdict-is-it-right-for-you"><strong>The Verdict: Is it right for you?</strong></h2>
<p>Headless is a powerful tool, but it’s a business decision, not just a technical one. I usually recommend it only if:</p>
<ol>
<li><p><strong>Scale:</strong> You are a high-volume merchant where a 500ms speed improvement translates to significant revenue.</p>
</li>
<li><p><strong>Specific Utility:</strong> You require a complex UI that is impossible to build within Liquid constraints.</p>
</li>
<li><p><strong>Resources:</strong> You have a dedicated engineering team or an agency retainer to manage the ongoing technical debt.</p>
</li>
</ol>
<p>For many, a <strong>well-optimized Liquid theme</strong> remains the most agile and cost-effective way to scale. But for those ready to push the boundaries of e-commerce, headless is the frontier.</p>
<hr />
<p><strong>Are you considering making the switch?</strong><br />I’d love to hear your thoughts in the comments or help you audit your current tech stack to see which path fits your 2026 growth goals.</p>
]]></content:encoded></item><item><title><![CDATA[Problem 14: Check if a Number is Prime]]></title><description><![CDATA[Hey everyone! 👋
Today, we're solving a classic mathematical problem: Checking if a Number is Prime.
The Problem
The goal is to write a function that determines whether a given number is prime.

A prime number is a natural number greater than 1 that ...]]></description><link>https://blog.vicentereyes.org/problem-14-check-if-a-number-is-prime</link><guid isPermaLink="true">https://blog.vicentereyes.org/problem-14-check-if-a-number-is-prime</guid><category><![CDATA[Python]]></category><category><![CDATA[Beginner Developers]]></category><category><![CDATA[learning]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Mon, 16 Feb 2026 13:39:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771249157164/23553987-674f-43b7-8f99-cc57e562e77b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Hey everyone! 👋</p>
<p>Today, we're solving a classic mathematical problem: <strong>Checking if a Number is Prime</strong>.</p>
<h2 id="heading-the-problem"><strong>The Problem</strong></h2>
<p>The goal is to write a function that determines whether a given number is prime.</p>
<ul>
<li><p>A <strong>prime number</strong> is a natural number greater than 1 that has no positive divisors other than 1 and itself.</p>
</li>
<li><p>The function should return <code>True</code> if the number is prime, and <code>False</code> otherwise.</p>
</li>
</ul>
<p><strong>Examples:</strong></p>
<ul>
<li><p><code>is_prime(7)</code> → Should return <code>True</code> (7 is only divisible by 1 and 7)</p>
</li>
<li><p><code>is_prime(10)</code> → Should return <code>False</code> (10 is divisible by 1, 2, 5, and 10)</p>
</li>
<li><p><code>is_prime(2)</code> → Should return <code>True</code> (2 is the only even prime number)</p>
</li>
</ul>
<h2 id="heading-the-solution"><strong>The Solution</strong></h2>
<p>Here is the Python implementation:</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">is_prime</span>(<span class="hljs-params">n</span>):</span>
    <span class="hljs-string">"""
    Checks if a number is prime.
    """</span>
    <span class="hljs-comment"># Numbers ≤ 1 are not prime by definition</span>
    <span class="hljs-keyword">if</span> n &lt;= <span class="hljs-number">1</span>:
        <span class="hljs-keyword">return</span> <span class="hljs-literal">False</span>

    <span class="hljs-comment"># 2 is the only even prime number</span>
    <span class="hljs-keyword">if</span> n == <span class="hljs-number">2</span>:
        <span class="hljs-keyword">return</span> <span class="hljs-literal">True</span>

    <span class="hljs-comment"># Check if n is even (and not 2)</span>
    <span class="hljs-keyword">if</span> n % <span class="hljs-number">2</span> == <span class="hljs-number">0</span>:
        <span class="hljs-keyword">return</span> <span class="hljs-literal">False</span>

    <span class="hljs-comment"># Check odd divisors from 3 up to the square root of n</span>
    <span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> range(<span class="hljs-number">3</span>, int(n**<span class="hljs-number">0.5</span>) + <span class="hljs-number">1</span>, <span class="hljs-number">2</span>):
        <span class="hljs-keyword">if</span> n % i == <span class="hljs-number">0</span>:
            <span class="hljs-keyword">return</span> <span class="hljs-literal">False</span>

    <span class="hljs-keyword">return</span> <span class="hljs-literal">True</span>

<span class="hljs-comment"># Test cases</span>
print(is_prime(<span class="hljs-number">7</span>))   <span class="hljs-comment"># True</span>
print(is_prime(<span class="hljs-number">10</span>))  <span class="hljs-comment"># False</span>
print(is_prime(<span class="hljs-number">2</span>))   <span class="hljs-comment"># True</span>
</code></pre>
<h2 id="heading-code-breakdown"><strong>Code Breakdown</strong></h2>
<p>Let's walk through the code line by line:</p>
<ol>
<li><p><code>def is_prime(n):</code></p>
<ul>
<li>Defines a function named <code>is_prime</code> that takes an integer <code>n</code> as input.</li>
</ul>
</li>
<li><p><code>if n &lt;= 1:</code></p>
<ul>
<li><p>Checks if the number is less than or equal to 1.</p>
</li>
<li><p>By mathematical definition, numbers ≤ 1 are not considered prime, so we return <code>False</code>.</p>
</li>
</ul>
</li>
<li><p><code>if n == 2:</code></p>
<ul>
<li><p>Special case: 2 is the <strong>only even prime number</strong>.</p>
</li>
<li><p>If <code>n</code> is 2, we immediately return <code>True</code>.</p>
</li>
</ul>
</li>
<li><p><code>if n % 2 == 0:</code></p>
<ul>
<li><p>Checks if <code>n</code> is even (divisible by 2).</p>
</li>
<li><p>Since we've already handled the case where <code>n == 2</code>, any other even number is not prime.</p>
</li>
<li><p>Returns <code>False</code> for all even numbers greater than 2.</p>
</li>
</ul>
</li>
<li><p><code>for i in range(3, int(n**0.5) + 1, 2):</code></p>
<ul>
<li><p>This is the optimization key! We only need to check divisors up to the <strong>square root of n</strong>.</p>
</li>
<li><p><strong>Why?</strong> If <code>n</code> has a divisor greater than √n, it must also have a corresponding divisor smaller than √n.</p>
</li>
<li><p><code>n**0.5</code> calculates the square root of <code>n</code>.</p>
</li>
<li><p><code>int(...)</code> converts it to an integer.</p>
</li>
<li><p><code>+ 1</code> ensures we include the square root itself in the range (since <code>range</code> is exclusive of the end value).</p>
</li>
<li><p><code>2</code> as the step means we only check <strong>odd numbers</strong> (3, 5, 7, 9, ...), skipping all even numbers since we already know they're not prime.</p>
</li>
</ul>
</li>
<li><p><code>if n % i == 0:</code></p>
<ul>
<li><p>Checks if <code>n</code> is divisible by the current value of <code>i</code>.</p>
</li>
<li><p>If it is, <code>n</code> has a divisor other than 1 and itself, so it's not prime.</p>
</li>
<li><p>Returns <code>False</code> immediately.</p>
</li>
</ul>
</li>
<li><p><code>return True</code></p>
<ul>
<li><p>If we've checked all possible divisors and found none, the number is prime.</p>
</li>
<li><p>Returns <code>True</code>.</p>
</li>
</ul>
</li>
</ol>
<h2 id="heading-example-walkthrough"><strong>Example Walkthrough</strong></h2>
<p>Let's trace the function with <code>is_prime(29)</code>:</p>
<ol>
<li><p><strong>Check:</strong> <code>29 &lt;= 1</code>? No, continue.</p>
</li>
<li><p><strong>Check:</strong> <code>29 == 2</code>? No, continue.</p>
</li>
<li><p><strong>Check:</strong> <code>29 % 2 == 0</code>? No (29 is odd), continue.</p>
</li>
<li><p><strong>Loop:</strong> Check divisors from 3 to √29 ≈ 5.38, so we check 3 and 5.</p>
<ul>
<li><p><code>i = 3</code>: <code>29 % 3 == 0</code>? No (29 ÷ 3 = 9 remainder 2)</p>
</li>
<li><p><code>i = 5</code>: <code>29 % 5 == 0</code>? No (29 ÷ 5 = 5 remainder 4)</p>
</li>
</ul>
</li>
<li><p><strong>Result:</strong> No divisors found, return <code>True</code>. ✅</p>
</li>
</ol>
<p>Now let's trace <code>is_prime(15)</code>:</p>
<ol>
<li><p><strong>Check:</strong> <code>15 &lt;= 1</code>? No, continue.</p>
</li>
<li><p><strong>Check:</strong> <code>15 == 2</code>? No, continue.</p>
</li>
<li><p><strong>Check:</strong> <code>15 % 2 == 0</code>? No (15 is odd), continue.</p>
</li>
<li><p><strong>Loop:</strong> Check divisors from 3 to √15 ≈ 3.87, so we check 3.</p>
<ul>
<li><code>i = 3</code>: <code>15 % 3 == 0</code>? Yes! (15 ÷ 3 = 5)</li>
</ul>
</li>
<li><p><strong>Result:</strong> Divisor found, return <code>False</code>. ❌</p>
</li>
</ol>
<h2 id="heading-key-optimizations"><strong>Key Optimizations</strong></h2>
<p>This implementation uses several clever optimizations:</p>
<ul>
<li><p><strong>Early Returns:</strong> We return <code>False</code> as soon as we find any divisor, avoiding unnecessary checks.</p>
</li>
<li><p><strong>Reduced Search Space:</strong> Only checking up to √n dramatically reduces the number of iterations.</p>
</li>
<li><p><strong>Skip Even Numbers:</strong> After handling 2, we skip all other even numbers by stepping by 2 in our loop.</p>
</li>
</ul>
<p>These optimizations make the function efficient even for larger numbers! 🚀</p>
<hr />
<p>Happy coding! 💻</p>
]]></content:encoded></item><item><title><![CDATA[Dev Log: Modernizing the Oatopia Shopify Experience]]></title><description><![CDATA[Weekly Sprint Report: February 4 – February 6, 2026
This week, we executed a comprehensive technical overhaul of the Oatopia Shopify theme. Our focus shifted from foundational modernization of legacy assets to high-level performance tuning and UX pol...]]></description><link>https://blog.vicentereyes.org/dev-log-modernizing-the-oatopia-shopify-experience</link><guid isPermaLink="true">https://blog.vicentereyes.org/dev-log-modernizing-the-oatopia-shopify-experience</guid><category><![CDATA[shopify]]></category><category><![CDATA[performance]]></category><category><![CDATA[Frontend Development]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Fri, 13 Feb 2026 10:51:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770960463730/2c4e845c-5826-4b7a-a760-7c3b168db45f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Weekly Sprint Report: February 4 – February 6, 2026</strong></p>
<p>This week, we executed a comprehensive technical overhaul of the Oatopia Shopify theme. Our focus shifted from foundational modernization of legacy assets to high-level performance tuning and UX polishing.</p>
<hr />
<h2 id="heading-performance-amp-core-web-vitals">⚡ Performance &amp; Core Web Vitals</h2>
<p>Speed is a feature. We spent significant time reducing page weight and optimizing how the browser handles our most important assets.</p>
<ul>
<li><p><strong>Image Modernization</strong>: Conducted a project-wide audit to replace legacy filters (<code>img_url</code>, <code>file_img_url</code>, <code>asset_img_url</code>) with the modern <code>image_url</code> standard.</p>
</li>
<li><p><strong>Next-Gen Delivery</strong>: Enabled automatic WebP/AVIF delivery via Shopify CDN and implemented <code>loading="lazy"</code> for off-screen images.</p>
</li>
<li><p><strong>LCP Optimization</strong>: Updated the collection banner to use <code>fetchpriority="high"</code>, signaling the browser to prioritize the hero image immediately.</p>
</li>
<li><p><strong>HTML Bloat Reduction</strong>: Refactored the QuickBuy feature to use a minimal manual JSON object instead of injecting massive data for every product card, drastically reducing page weight.</p>
</li>
<li><p><strong>Asset Loading</strong>: Moved <code>global.bundle.js</code> to the end of the body to unblock rendering and implemented a "print" media trick for non-blocking Adobe Typekit fonts.</p>
</li>
</ul>
<hr />
<h2 id="heading-navigation-amp-header-architecture">🗺️ Navigation &amp; Header Architecture</h2>
<p>We addressed several layout shifts and usability hurdles within the site's navigation.</p>
<ul>
<li><p><strong>Smart Hover Logic</strong>: Added a 150ms grace period to dropdowns and implemented auto-close logic for adjacent menus to prevent overlapping during mouse sweeps.</p>
</li>
<li><p><strong>Desktop-to-Mobile Parity</strong>: Fixed an issue where secondary links like "Account" were missing from the mobile drawer by updating <code>snippets/sidebar-nav.liquid</code>.</p>
</li>
<li><p><strong>Mega Menu Styling</strong>: Updated the layout to support a 3-column categorical structure (e.g., Shop &gt; Oat Bakes, Flapjacks, Giftboxes).</p>
</li>
<li><p><strong>Flexibility</strong>: Removed hardcoded CSS constraints to restore dynamic logo sizing based on theme settings.</p>
</li>
</ul>
<hr />
<h2 id="heading-uiux-amp-visual-consistency">🖌️ UI/UX &amp; Visual Consistency</h2>
<p>Refining the "feel" of the store to match the Oatopia brand identity.</p>
<ul>
<li><p><strong>Grid Stability</strong>: Optimized product grids to ensure rows are fully filled (no empty slots).</p>
</li>
<li><p><strong>Mobile "Gap" Fix</strong>: Forced mobile 2-column grids to always be divisible by 2 to prevent "hanging" items at the end of a page.</p>
</li>
<li><p><strong>Interactive Polish</strong>: Added a "Zoom + Bold" effect for dropdown items and increased horizontal padding on "View All" buttons for better prominence.</p>
</li>
<li><p><strong>Brand Styling</strong>: Customized the footer "Subscribe" button with a Blueberry background and Demarara text.</p>
</li>
<li><p><strong>Layout Fixes</strong>: Applied <code>flex-nowrap</code> to the header to prevent the Search and Cart icons from "squishing" on smaller desktop screens.</p>
</li>
</ul>
<hr />
<h2 id="heading-technical-fixes-amp-seo">🛠️ Technical Fixes &amp; SEO</h2>
<p>Behind-the-scenes improvements for long-term site health.</p>
<ul>
<li><p><strong>Scroll Locking</strong>: Implemented JavaScript handlers for full body-scroll locking when menus are open to prevent secondary page scrolling.</p>
</li>
<li><p><strong>SEO Hardening</strong>: Applied <code>rel="nofollow"</code> to all raw image thumbnail links to prioritize high-value page indexing.</p>
</li>
<li><p><strong>Enhanced Product Blocks</strong>: Updated the product template schema to support multiple independent, per-product FAQ/Collapsible blocks.</p>
</li>
<li><p><strong>Alpine.js Integration</strong>: Enhanced the menu state management using Alpine.js to handle hover delays and click-outside events effectively.</p>
</li>
</ul>
<h2 id="heading-conclusion">🏁 Conclusion</h2>
<p>By the end of this sprint, all core modernization, performance optimization, and layout stabilization tasks have been completed and verified on the development theme. The site now boasts a significantly more robust navigation system and a leaner code architecture, particularly regarding image handling and HTML delivery</p>
]]></content:encoded></item><item><title><![CDATA[Problem 13: Group Anagrams]]></title><description><![CDATA[Hey everyone! 👋
Today, we're tackling a popular interview question: Group Anagrams.
The Problem
The goal is to write a function that groups words that are anagrams of each other from a given list of strings.

An anagram is a word or phrase formed by...]]></description><link>https://blog.vicentereyes.org/problem-13-group-anagrams</link><guid isPermaLink="true">https://blog.vicentereyes.org/problem-13-group-anagrams</guid><category><![CDATA[Python]]></category><category><![CDATA[Beginner Developers]]></category><category><![CDATA[learning]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Tue, 10 Feb 2026 02:53:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770691998031/e98d92f2-714e-4228-b7a3-6b87a6a7572f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Hey everyone! 👋</p>
<p>Today, we're tackling a popular interview question: <strong>Group Anagrams</strong>.</p>
<h2 id="heading-the-problem"><strong>The Problem</strong></h2>
<p>The goal is to write a function that groups words that are anagrams of each other from a given list of strings.</p>
<ul>
<li><p>An <strong>anagram</strong> is a word or phrase formed by rearranging the letters of a different word or phrase, typically using all the original letters exactly once.</p>
</li>
<li><p>The function should return a list of lists, where each sublist contains words that are anagrams.</p>
</li>
</ul>
<p><strong>Example:</strong></p>
<ul>
<li><p><code>group_anagrams(['listen', 'silent', 'hello', 'world', 'enlist'])</code></p>
<ul>
<li>Should return: <code>[['listen', 'silent', 'enlist'], ['hello'], ['world']]</code></li>
</ul>
</li>
</ul>
<h2 id="heading-the-solution"><strong>The Solution</strong></h2>
<p>Here is the Python implementation:</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">group_anagrams</span>(<span class="hljs-params">words</span>):</span>
    <span class="hljs-string">"""
    Groups words that are anagrams of each other.
    """</span>
    <span class="hljs-comment"># Handle edge case: if input is None or empty list</span>
    <span class="hljs-keyword">if</span> words <span class="hljs-keyword">is</span> <span class="hljs-literal">None</span> <span class="hljs-keyword">or</span> len(words) == <span class="hljs-number">0</span>:
        <span class="hljs-keyword">return</span> []

    anagram_groups = {}

    <span class="hljs-keyword">for</span> word <span class="hljs-keyword">in</span> words:
        <span class="hljs-comment"># Create a key that is the same for all anagrams</span>
        <span class="hljs-comment"># 1. Lowercase to ensure case-insensitivity</span>
        <span class="hljs-comment"># 2. Sort the characters</span>
        <span class="hljs-comment"># 3. Convert to tuple to make it hashable (usable as a dict key)</span>
        key = tuple(sorted(word.lower()))

        <span class="hljs-keyword">if</span> key <span class="hljs-keyword">in</span> anagram_groups:
            anagram_groups[key].append(word)
        <span class="hljs-keyword">else</span>:
            anagram_groups[key] = [word]

    <span class="hljs-keyword">return</span> list(anagram_groups.values())

<span class="hljs-comment"># Test cases</span>
print(group_anagrams([<span class="hljs-string">'listen'</span>, <span class="hljs-string">'silent'</span>, <span class="hljs-string">'hello'</span>, <span class="hljs-string">'world'</span>, <span class="hljs-string">'enlist'</span>]))
<span class="hljs-comment"># Output: [['listen', 'silent', 'enlist'], ['hello'], ['world']]</span>
</code></pre>
<h2 id="heading-code-breakdown"><strong>Code Breakdown</strong></h2>
<p>Let's walk through the code line by line:</p>
<ol>
<li><p><code>def group_anagrams(words):</code></p>
<ul>
<li>Defines a function named <code>group_anagrams</code> that takes a list of strings <code>words</code> as input.</li>
</ul>
</li>
<li><p><code>if words is None or len(words) == 0:</code></p>
<ul>
<li>Checks if the input list is <code>None</code> or empty. If so, it returns an empty list <code>[]</code>. This handles edge cases effectively.</li>
</ul>
</li>
<li><p><code>anagram_groups = {}</code></p>
<ul>
<li><p>Initializes an empty dictionary called <code>anagram_groups</code>.</p>
</li>
<li><p><strong>Key</strong>: A sorted tuple of characters (unique signature for anagrams).</p>
</li>
<li><p><strong>Value</strong>: A list of words that match that signature.</p>
</li>
</ul>
</li>
<li><p><code>for word in words:</code></p>
<ul>
<li>Iterates through each <code>word</code> in the input list.</li>
</ul>
</li>
<li><p><code>key = tuple(sorted(word.lower()))</code></p>
<ul>
<li><p><code>word.lower()</code>: Converts the word to lowercase so 'Listen' and 'listen' are treated the same.</p>
</li>
<li><p><code>sorted(...)</code>: Sorts the characters alphabetically. For example, 'listen' becomes <code>['e', 'i', 'l', 'n', 's', 't']</code>.</p>
</li>
<li><p><code>tuple(...)</code>: Converts the sorted list into a tuple. This is crucial because lists are mutable and cannot be used as dictionary keys, but tuples are immutable and hashable.</p>
</li>
</ul>
</li>
<li><p><code>if key in anagram_groups:</code></p>
<ul>
<li>Checks if this sorted character tuple (the key) is already in our dictionary.</li>
</ul>
</li>
<li><p><code>anagram_groups[key].append(word)</code></p>
<ul>
<li>If the key exists, it means we've found another anagram for this group. We append the original <code>word</code> to the list associated with this key.</li>
</ul>
</li>
<li><p><code>else: anagram_groups[key] = [word]</code></p>
<ul>
<li>If the key does not exist, this is the first word of this anagram type we've encountered. We create a new entry in the dictionary with the key and a new list containing the current <code>word</code>.</li>
</ul>
</li>
<li><p><code>return list(anagram_groups.values())</code></p>
<ul>
<li><p><code>anagram_groups.values()</code> retrieves all the lists of anagrams from the dictionary.</p>
</li>
<li><p><code>list(...)</code> converts this view object into a standard list and returns it.</p>
</li>
</ul>
</li>
</ol>
<h2 id="heading-example-walkthrough"><strong>Example Walkthrough</strong></h2>
<p>Let's trace the function with <code>['listen', 'silent', 'hello']</code>:</p>
<ol>
<li><p><strong>Initialization:</strong> <code>anagram_groups = {}</code></p>
</li>
<li><p><strong>Word 1:</strong> <code>'listen'</code></p>
<ul>
<li><p><code>key</code>: <code>('e', 'i', 'l', 'n', 's', 't')</code></p>
</li>
<li><p>Key not in map. Add it: <code>anagram_groups = {('e', 'i', 'l', 'n', 's', 't'): ['listen']}</code></p>
</li>
</ul>
</li>
<li><p><strong>Word 2:</strong> <code>'silent'</code></p>
<ul>
<li><p><code>key</code>: <code>('e', 'i', 'l', 'n', 's', 't')</code> (Same as 'listen'!)</p>
</li>
<li><p>Key exists. Append: <code>anagram_groups = {('e', 'i', 'l', 'n', 's', 't'): ['listen', 'silent']}</code></p>
</li>
</ul>
</li>
<li><p><strong>Word 3:</strong> <code>'hello'</code></p>
<ul>
<li><p><code>key</code>: <code>('e', 'h', 'l', 'l', 'o')</code></p>
</li>
<li><p>Key not in map. Add it.</p>
</li>
</ul>
</li>
</ol>
<p><strong>Final Result:</strong> <code>list(anagram_groups.values())</code> -&gt; <code>[['listen', 'silent'], ['hello']]</code></p>
<hr />
<p>Happy coding! 💻</p>
]]></content:encoded></item><item><title><![CDATA[Boosting LCP: A Guide to fetchpriority="high"]]></title><description><![CDATA[In the world of web performance, every millisecond counts. We’ve all been there: you open a site, the text loads, but the main hero image—the thing you actually want to see—stays blank for an extra second. That lag often kills your Largest Contentful...]]></description><link>https://blog.vicentereyes.org/boosting-lcp-a-guide-to-fetchpriorityhigh</link><guid isPermaLink="true">https://blog.vicentereyes.org/boosting-lcp-a-guide-to-fetchpriorityhigh</guid><category><![CDATA[a11y]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Programming Blogs]]></category><dc:creator><![CDATA[Vicente Reyes]]></dc:creator><pubDate>Fri, 06 Feb 2026 11:18:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770376630427/5f0c99d4-356c-4fca-879f-0949398f8e6e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the world of web performance, every millisecond counts. We’ve all been there: you open a site, the text loads, but the main hero image—the thing you actually want to see—stays blank for an extra second. That lag often kills your <strong>Largest Contentful Paint (LCP)</strong> score.</p>
<p>Enter <code>fetchpriority</code>. This HTML attribute is a game-changer for telling browsers exactly which assets deserve the "VIP treatment."</p>
<hr />
<h2 id="heading-what-is-fetchpriority">What is <code>fetchpriority</code>?</h2>
<p>The <code>fetchpriority</code> attribute is a browser hint that signals the relative importance of a resource. While browsers are generally smart at guessing what to load first, they aren't psychic. They might prioritize a late-discovery script over the massive hero image that is actually the most important thing on the screen.</p>
<p>By adding <code>fetchpriority="high"</code>, you’re moving that asset to the front of the line.</p>
<h3 id="heading-how-it-works">How it Works</h3>
<p>When a browser parses a page, it assigns a priority level (Low, Medium, High, or Very High) to every resource.</p>
<ul>
<li><p><strong>Images</strong> are usually "Low" or "Medium" by default.</p>
</li>
<li><p><strong>Scripts</strong> and <strong>CSS</strong> are usually "High."</p>
</li>
</ul>
<p>By manually setting the priority, you override these defaults to ensure your "hero" elements don't get stuck behind less critical background tasks.</p>
<hr />
<h2 id="heading-why-use-it-the-lcp-connection">Why Use It? (The LCP Connection)</h2>
<p><strong>Largest Contentful Paint (LCP)</strong> measures when the largest image or text block becomes visible. If your hero image is the LCP element, <code>fetchpriority="high"</code> can:</p>
<ol>
<li><p><strong>Reduce Queueing Time:</strong> The browser starts downloading the image sooner.</p>
</li>
<li><p><strong>Optimize Bandwidth:</strong> It allocates more "pipe" to that specific asset.</p>
</li>
<li><p><strong>Improve UX:</strong> Users see the "meat" of your page faster, reducing bounce rates.</p>
</li>
</ol>
<blockquote>
<p><strong>Note:</strong> Real-world tests have shown that correctly implementing <code>fetchpriority</code> can improve LCP by <strong>20-30%</strong> in many cases.</p>
</blockquote>
<hr />
<h2 id="heading-how-to-implement-it">How to Implement It</h2>
<p>You can apply this attribute to <code>&lt;img&gt;</code>, <code>&lt;link&gt;</code>, <code>&lt;script&gt;</code>, and even <code>&lt;iframe&gt;</code> tags.</p>
<h3 id="heading-1-for-hero-images">1. For Hero Images</h3>
<p>This is the most common use case. If you have an image above the fold, give it the high-priority tag.</p>
<p>HTML</p>
<pre><code class="lang-python">&lt;img src=<span class="hljs-string">"hero-banner.jpg"</span> fetchpriority=<span class="hljs-string">"high"</span> alt=<span class="hljs-string">"New Summer Collection"</span>&gt;
</code></pre>
<h3 id="heading-2-for-preloaded-resources">2. For Preloaded Resources</h3>
<p>If you are preloading a critical asset in the <code>&lt;head&gt;</code>, you can specify the priority there as well.</p>
<p>HTML</p>
<pre><code class="lang-python">&lt;link rel=<span class="hljs-string">"preload"</span> href=<span class="hljs-string">"main-style.css"</span> <span class="hljs-keyword">as</span>=<span class="hljs-string">"style"</span> fetchpriority=<span class="hljs-string">"high"</span>&gt;
</code></pre>
<h3 id="heading-3-deprioritizing-non-critical-assets">3. Deprioritizing Non-Critical Assets</h3>
<p>Conversely, you can use <code>fetchpriority="low"</code> for things that don't matter immediately, like images further down the page (below the fold) or a non-essential tracking script.</p>
<hr />
<h2 id="heading-best-practices-to-keep-in-mind">Best Practices to Keep in Mind</h2>
<ul>
<li><p><strong>Don't Overdo It:</strong> If everything is "high" priority, nothing is. Only use it for the 1 or 2 most critical elements on the screen.</p>
</li>
<li><p><strong>Combine with</strong> <code>loading="lazy"</code>: Use <code>fetchpriority="high"</code> for the top of the page and <code>loading="lazy"</code> for everything else. <strong>Never use both on the same image.</strong></p>
</li>
<li><p><strong>Test with DevTools:</strong> You can see the "Priority" column in the <strong>Network Tab</strong> of Chrome DevTools to verify if your hint is working.</p>
</li>
</ul>
<hr />
<h2 id="heading-conclusion">Conclusion</h2>
<p><code>fetchpriority="high"</code> is one of the simplest yet most effective tools in a developer's performance toolkit. It’s not a magic wand, but it’s a very loud megaphone that helps the browser focus on what truly matters to your users.</p>
]]></content:encoded></item></channel></rss>