<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Frank Mendez</title>
    <description>The latest articles on DEV Community by Frank Mendez (@frankmendez).</description>
    <link>https://dev.to/frankmendez</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F851284%2F66782c4d-188c-4e01-8161-93796f702126.png</url>
      <title>DEV Community: Frank Mendez</title>
      <link>https://dev.to/frankmendez</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/frankmendez"/>
    <language>en</language>
    <item>
      <title>🚀 Building a Newsletter Subscription Feature That Actually Works (No BS Edition)</title>
      <dc:creator>Frank Mendez</dc:creator>
      <pubDate>Tue, 21 Apr 2026 09:14:33 +0000</pubDate>
      <link>https://dev.to/frankmendez/building-a-newsletter-subscription-feature-that-actually-works-no-bs-edition-3h7h</link>
      <guid>https://dev.to/frankmendez/building-a-newsletter-subscription-feature-that-actually-works-no-bs-edition-3h7h</guid>
      <description>&lt;h2&gt;
  
  
  The Problem With Most “Newsletter Features”
&lt;/h2&gt;

&lt;p&gt;Let’s be honest—most newsletter setups are either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overkill (hello 20-step Mailchimp workflows)&lt;/li&gt;
&lt;li&gt;Manual (someone forgets to hit “send”)&lt;/li&gt;
&lt;li&gt;Or duct-taped together like a weekend hackathon project&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What we actually want is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;User subscribes → You publish → Email goes out automatically&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That’s it. No ceremony.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 The Approach: Keep It Lean
&lt;/h2&gt;

&lt;p&gt;This feature was built into a Blog CMS with a few strict rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No new vendors (use existing stack)&lt;/li&gt;
&lt;li&gt;No double opt-in friction&lt;/li&gt;
&lt;li&gt;Fully automated sending&lt;/li&gt;
&lt;li&gt;One-click unsubscribe (because lawsuits are expensive 😅)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;👉 Full design spec: &lt;/p&gt;




&lt;h2&gt;
  
  
  ⚙️ Core Architecture
&lt;/h2&gt;

&lt;p&gt;Instead of relying on external newsletter platforms, this setup runs fully in-house:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; Supabase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email:&lt;/strong&gt; Resend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduler:&lt;/strong&gt; Vercel Cron&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; Next.js&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Flow (a.k.a. “what actually happens”)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;User enters email → stored in DB&lt;/li&gt;
&lt;li&gt;Admin publishes a post&lt;/li&gt;
&lt;li&gt;System schedules a send (with delay)&lt;/li&gt;
&lt;li&gt;Cron job runs every minute&lt;/li&gt;
&lt;li&gt;Emails are sent automatically&lt;/li&gt;
&lt;li&gt;User can unsubscribe with one click&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No dashboards. No manual triggers. No “oops we forgot to send.”&lt;/p&gt;




&lt;h2&gt;
  
  
  🗄️ Data Model (Simple but Powerful)
&lt;/h2&gt;

&lt;p&gt;Two tables. That’s it.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;newsletter_subscriptions&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Tracks subscribers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;email (unique)&lt;/li&gt;
&lt;li&gt;subscribed_at&lt;/li&gt;
&lt;li&gt;unsubscribed_at (null = active)&lt;/li&gt;
&lt;li&gt;unsubscribe_token (for one-click unsubscribe)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;newsletter_sends&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Acts like a queue:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;post_id (1 send per post)&lt;/li&gt;
&lt;li&gt;scheduled_at&lt;/li&gt;
&lt;li&gt;status (pending → sending → sent/failed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This separation is key. It lets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Schedule emails&lt;/li&gt;
&lt;li&gt;Retry failures&lt;/li&gt;
&lt;li&gt;Track delivery&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ⏱️ The Secret Sauce: Delayed Sending
&lt;/h2&gt;

&lt;p&gt;Instead of blasting emails immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEWSLETTER_DELAY_MINUTES=60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gives you time to fix mistakes after publishing&lt;/li&gt;
&lt;li&gt;Avoids “oops typo in production” emails&lt;/li&gt;
&lt;li&gt;Feels more intentional&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🔁 Automation via Cron
&lt;/h2&gt;

&lt;p&gt;A Vercel cron job runs every minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; → /api/newsletter/cron
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Any &lt;code&gt;pending&lt;/code&gt; sends?&lt;/li&gt;
&lt;li&gt;Is &lt;code&gt;scheduled_at &amp;lt;= now()&lt;/code&gt;?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If yes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mark as &lt;code&gt;sending&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Fetch active subscribers&lt;/li&gt;
&lt;li&gt;Send emails&lt;/li&gt;
&lt;li&gt;Update status&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If something crashes midway?&lt;/p&gt;

&lt;p&gt;👉 Anything stuck in &lt;code&gt;sending&lt;/code&gt; for 10+ minutes is marked as &lt;code&gt;failed&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;No silent failures. No ghost jobs.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✉️ Subscription Experience (UX Matters)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Subscribe Form
&lt;/h3&gt;

&lt;p&gt;Placed at the bottom of every blog post:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Email input&lt;/li&gt;
&lt;li&gt;Subscribe button&lt;/li&gt;
&lt;li&gt;Instant feedback (success or error)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No confirmation email. No friction.&lt;/p&gt;

&lt;p&gt;Because let’s be real—every extra step kills conversions.&lt;/p&gt;




&lt;h2&gt;
  
  
  ❌ Unsubscribe (Don’t Mess This Up)
&lt;/h2&gt;

&lt;p&gt;Every email includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/api/newsletter/unsubscribe?token=xxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Click → done.&lt;/p&gt;

&lt;p&gt;No login. No “are you sure?” guilt trips.&lt;/p&gt;

&lt;p&gt;Just clean, respectful UX.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛡️ Edge Cases You’ll Actually Hit
&lt;/h2&gt;

&lt;p&gt;Handled upfront:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate email&lt;/td&gt;
&lt;td&gt;“Already subscribed”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-subscribe&lt;/td&gt;
&lt;td&gt;Reactivates user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid token&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No subscribers&lt;/td&gt;
&lt;td&gt;Send marked as complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron crash&lt;/td&gt;
&lt;td&gt;Auto-mark failed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is where most systems break. Don’t skip it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧪 Testing Strategy
&lt;/h2&gt;

&lt;p&gt;Because “it works on my machine” isn’t a strategy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit tests:&lt;/strong&gt; subscribe/unsubscribe logic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration tests:&lt;/strong&gt; cron + email dispatch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;E2E tests:&lt;/strong&gt; full user flow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yes, even for a “simple” feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  💡 Why This Approach Wins
&lt;/h2&gt;

&lt;p&gt;Let’s compare:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mailchimp&lt;/td&gt;
&lt;td&gt;Expensive + overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual send&lt;/td&gt;
&lt;td&gt;Someone forgets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zapier hacks&lt;/td&gt;
&lt;td&gt;Fragile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;This system&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clean, automatic, predictable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You control everything. No black boxes.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;This isn’t just a newsletter feature—it’s an &lt;strong&gt;event-driven system&lt;/strong&gt; disguised as one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Publish event → triggers queue&lt;/li&gt;
&lt;li&gt;Queue → processed by cron&lt;/li&gt;
&lt;li&gt;State machine → tracks delivery&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s simple on the surface, but solid under the hood.&lt;/p&gt;

&lt;p&gt;And that’s the sweet spot.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔥 If You’re Building Something Similar…
&lt;/h2&gt;

&lt;p&gt;Start here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep your data model clean&lt;/li&gt;
&lt;li&gt;Automate everything&lt;/li&gt;
&lt;li&gt;Design for failure (because it &lt;em&gt;will&lt;/em&gt; happen)&lt;/li&gt;
&lt;li&gt;Avoid adding tools just because they exist&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🏁 TL;DR
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;If your newsletter requires manual effort, it’s broken.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Automate it. Keep it lean. Ship it.&lt;/p&gt;

</description>
      <category>newsletter</category>
      <category>supabase</category>
      <category>vercel</category>
      <category>cronjob</category>
    </item>
    <item>
      <title>Notifying Admins When Users Confirm Their Email — The Right Way with Supabase and Next.js"</title>
      <dc:creator>Frank Mendez</dc:creator>
      <pubDate>Thu, 16 Apr 2026 10:12:24 +0000</pubDate>
      <link>https://dev.to/frankmendez/notifying-admins-when-users-confirm-their-email-the-right-way-with-supabase-and-nextjs-23o6</link>
      <guid>https://dev.to/frankmendez/notifying-admins-when-users-confirm-their-email-the-right-way-with-supabase-and-nextjs-23o6</guid>
      <description>&lt;p&gt;Full blog =&amp;gt; &lt;a href="https://blog.frankmendez.site/blog/event-driven-user-notifications-with-supabase-webhooks-and-nextjs-no-edge-functions-needed" rel="noopener noreferrer"&gt;here&lt;/a&gt;&lt;br&gt;
Steps (Article Sections)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Hook / Problem Statement — Why polling or signup-time notifications are wrong; email confirmation is async and outside your app's request cycle.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Architecture Overview — The 6-step event chain diagram (confirmation link → auth.users.confirmed_at → Postgres trigger → public.profiles.confirmed_at → Supabase Database Webhook → Next.js API route). Why bridging via a public-schema table is necessary (Supabase webhooks can't observe auth.*).&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvvv64br7sg807wrawktk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvvv64br7sg807wrawktk.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Data Layer — The add_confirmed_at_to_profiles migration: adding the nullable confirmed_at column and the Postgres AFTER UPDATE trigger function handle_user_confirmed() that syncs it. Key insight: OLD.confirmed_at IS NULL AND NEW.confirmed_at IS NOT NULL guard.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Webhook API Route — app/api/webhooks/user-confirmed/route.ts. Cover: shared-secret verification via x-webhook-secret header (security), parsing Supabase's { type, table, record } payload, independent try/catch per notification channel, and why always returning 200 OK prevents Supabase retry storms.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Notification Services — user-confirmed.ts. Resend for email (HTML body with name/email/ID/timestamp), Slack Incoming Webhook via fetch. Emphasize isolation — one failure never blocks the other.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Supabase Dashboard Config — The one-time manual step: create the DB webhook pointing to the deployed route, set the header, add a confirmed_at IS NOT NULL filter to reduce noise.&lt;br&gt;
&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://supabase.com/images/features/database-webhooks.png" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;supabase.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Environment Variables — Table of the 4 new vars (RESEND_API_KEY, ADMIN_EMAIL, SLACK_WEBHOOK_URL, WEBHOOK_SECRET) with descriptions.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8rvrsjln4hjsai34bkms.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8rvrsjln4hjsai34bkms.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fggnb3ezlm71m64b03g3m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fggnb3ezlm71m64b03g3m.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;What's Out of Scope (and Why) — No retry logic (intentional), no multi-admin, no notification prefs UI — acknowledge these and link to potential follow-ups.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Closing — Summary of the pattern: Postgres trigger → public table → Supabase webhook → app route. Reusable for other auth events (password reset, MFA enrollment, etc.).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;
  
  
  Supabase Database Webhook Setup
&lt;/h1&gt;
&lt;h2&gt;
  
  
  User Confirmed Notification Webhook
&lt;/h2&gt;

&lt;p&gt;This webhook fires when a user confirms their email and triggers admin notifications (email + Slack).&lt;/p&gt;
&lt;h3&gt;
  
  
  One-time setup in Supabase Dashboard
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Database → Webhooks&lt;/strong&gt; in the Supabase Dashboard&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create a new hook&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Fill in the following fields:&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Name&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;user-confirmed-notification&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Table&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public.profiles&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Events&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UPDATE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Type&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HTTP Request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Method&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;URL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://your-domain.com/api/webhooks/user-confirmed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt;Under &lt;strong&gt;HTTP Headers&lt;/strong&gt;, add:&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;x-webhook-secret&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(value of your &lt;code&gt;WEBHOOK_SECRET&lt;/code&gt; env var)&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Confirm&lt;/strong&gt; to save.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  Required: add confirmed_at filter
&lt;/h3&gt;

&lt;p&gt;Without this filter, the webhook fires on every profile update (role changes, name updates, etc.), causing duplicate notifications. Add this filter condition:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Column:&lt;/strong&gt; &lt;code&gt;confirmed_at&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operator:&lt;/strong&gt; &lt;code&gt;is not&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value:&lt;/strong&gt; &lt;code&gt;null&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This ensures the webhook only fires when &lt;code&gt;confirmed_at&lt;/code&gt; is set.&lt;/p&gt;
&lt;h3&gt;
  
  
  Required environment variables
&lt;/h3&gt;

&lt;p&gt;Make sure these are set in &lt;code&gt;.env.local&lt;/code&gt; (dev) and in your Vercel / hosting environment (prod):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;RESEND_FROM_EMAIL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;ADMIN_EMAIL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>architecture</category>
      <category>nextjs</category>
      <category>postgres</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>🚀 I Built a Full-Stack Blog CMS with Next.js + Supabase (And You Can Use It Today)</title>
      <dc:creator>Frank Mendez</dc:creator>
      <pubDate>Wed, 15 Apr 2026 09:20:09 +0000</pubDate>
      <link>https://dev.to/frankmendez/i-built-a-full-stack-blog-cms-with-nextjs-supabase-and-you-can-use-it-today-23bk</link>
      <guid>https://dev.to/frankmendez/i-built-a-full-stack-blog-cms-with-nextjs-supabase-and-you-can-use-it-today-23bk</guid>
      <description>&lt;p&gt;&lt;em&gt;Updated October 26, 2026 — refreshed features, stack, setup, and links.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I built an open-source blog CMS with Next.js, Supabase, and TipTap. It now includes a richer publishing workflow, comments and reactions, author profiles, newsletters, and account security features. The live site is &lt;strong&gt;The Practical Engineer&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;👉 &lt;a href="https://www.thepracticalengineer.online/blog" rel="noopener noreferrer"&gt;Browse the live blog&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;👉 &lt;a href="https://www.thepracticalengineer.online/register" rel="noopener noreferrer"&gt;Create an account&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;👉 &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms" rel="noopener noreferrer"&gt;Get the source on GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;👉 &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms/blob/develop/docs/README.md" rel="noopener noreferrer"&gt;Read the documentation&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An important update for returning readers:&lt;/strong&gt; the standalone AI assistant and external Developer API described in the original article have been retired. Personal AI provider settings remain available; editor assistance is future work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  🤔 Why I Built This
&lt;/h2&gt;

&lt;p&gt;I wanted a practical place to write and publish without rebuilding authentication, permissions, an editor, and media handling for every project.&lt;/p&gt;

&lt;p&gt;That idea grew into a full-stack CMS. The interesting work has increasingly been in the details: keeping drafts private, protecting published URLs, recovering writing, and making publication and newsletter delivery behave predictably when requests fail.&lt;/p&gt;

&lt;p&gt;If you are learning Next.js and Supabase, those are useful problems to explore in a real codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 What the CMS Can Do Today
&lt;/h2&gt;

&lt;h3&gt;
  
  
  🔐 Accounts, roles, and security
&lt;/h3&gt;

&lt;p&gt;The app uses Supabase Auth with email/password authentication and support for Google and GitHub sign-in when configured. It includes email confirmation, password recovery, authenticator-based MFA, and onboarding for new accounts.&lt;/p&gt;

&lt;p&gt;Authors manage their own posts. Administrators manage users, posts, comments, and taxonomy. Authorization combines database Row Level Security with server-side identity, ownership, and role checks. Public author profiles expose a limited public view while private account data stays separate.&lt;/p&gt;

&lt;h3&gt;
  
  
  ✍️ A fuller writing and publishing workflow
&lt;/h3&gt;

&lt;p&gt;The TipTap rich-text editor supports formatting, links, tables, task lists, and images. Authors can organize posts with existing categories and searchable tags, add cover and inline images, and configure SEO metadata.&lt;/p&gt;

&lt;p&gt;The workflow includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Draft saving, writing recovery, and a private preview of unsaved content.&lt;/li&gt;
&lt;li&gt;A publication review step with server-side readiness checks and explicit human editorial confirmation.&lt;/li&gt;
&lt;li&gt;Atomic post and taxonomy saves, plus conflict detection to protect against stale edits.&lt;/li&gt;
&lt;li&gt;Published URL protection: changing an article URL is deliberate, and former published URLs redirect to the current one.&lt;/li&gt;
&lt;li&gt;Search, filters, sorting, pagination, and bulk unpublish/delete actions in the dashboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Previewing does not make a draft public or send a newsletter. Publishing checks content readiness, but a human still needs to review accuracy, usefulness, attribution, and metadata.&lt;/p&gt;

&lt;h3&gt;
  
  
  💬 Community and discovery
&lt;/h3&gt;

&lt;p&gt;Comments are now implemented, along with comment reactions and five article reaction types: Love, Unicorn, Mind blown, Celebrate, and Fire.&lt;/p&gt;

&lt;p&gt;Readers can explore public author profiles and category/tag pages. The app also includes reading-time estimates, SEO metadata, canonical URLs, and sitemap support. Comments and SEO are no longer just items on the original wishlist.&lt;/p&gt;

&lt;h3&gt;
  
  
  📬 Newsletter subscriptions
&lt;/h3&gt;

&lt;p&gt;Readers can subscribe from article pages. Publishing queues a newsletter notification through Resend with a configurable delay. The default delay is 60 minutes; delivery starts on a subsequent dispatcher run, rather than being guaranteed exactly an hour later.&lt;/p&gt;

&lt;p&gt;Authors can see notification status in the editor. Unpublishing cancels pending delivery, and administrators have a newsletter dashboard with subscriber information and CSV export. Messages include unsubscribe links.&lt;/p&gt;

&lt;h3&gt;
  
  
  🤖 What changed with AI and the API?
&lt;/h3&gt;

&lt;p&gt;The standalone PDF upload, book chat, and full-post generation workflows are retired. The external Developer API and developer-key interface are retired too; old endpoints return &lt;code&gt;410 API_RETIRED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Authors and administrators can still manage &lt;strong&gt;their own encrypted provider credentials&lt;/strong&gt; in &lt;strong&gt;Settings → AI Providers&lt;/strong&gt;. Credential verification checks provider access; it does not generate content. Administrators do not get access to another author's provider keys.&lt;/p&gt;

&lt;p&gt;Editor assistance is tracked separately as future work. This project should no longer be described as offering a supported headless API or a working standalone AI writing assistant.&lt;/p&gt;

&lt;p&gt;For details, see &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms/blob/develop/docs/features/ai-assistant.md" rel="noopener noreferrer"&gt;AI Providers and retirement&lt;/a&gt; and &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms/blob/develop/docs/api/developer-api.md" rel="noopener noreferrer"&gt;Developer API retirement&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛠️ Current Tech Stack
&lt;/h2&gt;

&lt;p&gt;These are the versions pinned in the repository when this update was written, rather than a claim about the latest upstream releases.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Application&lt;/td&gt;
&lt;td&gt;Next.js 16.4.2 App Router, React 19.3.1, TypeScript 6.0.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database, auth, media&lt;/td&gt;
&lt;td&gt;Supabase Postgres, Auth, Storage, and RLS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rich-text editor&lt;/td&gt;
&lt;td&gt;TipTap 3.31.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Tailwind CSS 4.3.3, shadcn/ui, Base UI, Lucide icons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forms and validation&lt;/td&gt;
&lt;td&gt;React Hook Form and Zod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client state&lt;/td&gt;
&lt;td&gt;Zustand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing&lt;/td&gt;
&lt;td&gt;Vitest, Testing Library, and Playwright&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;Vercel, with cron-based newsletter dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local toolchain&lt;/td&gt;
&lt;td&gt;Node 24.21.0 and pnpm 10.29.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;OpenAI, Anthropic, and Google SDK dependencies remain in the repository for provider settings and future work. Their presence does not mean content generation is currently available.&lt;/p&gt;

&lt;h2&gt;
  
  
  🚀 Run It Locally
&lt;/h2&gt;

&lt;p&gt;You will need the pinned Node and pnpm versions, plus Docker for local Supabase.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/thepracticalengineer/nextjs-blog-cms.git
&lt;span class="nb"&gt;cd &lt;/span&gt;nextjs-blog-cms
nvm &lt;span class="nb"&gt;install
&lt;/span&gt;nvm use
corepack &lt;span class="nb"&gt;enable
&lt;/span&gt;pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--frozen-lockfile&lt;/span&gt;
pnpm dlx supabase@2.119.0 start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then follow the &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms/blob/develop/docs/setup/local-development.md" rel="noopener noreferrer"&gt;local development guide&lt;/a&gt; to configure your local Supabase credentials and environment. Use the complete ordered migration history; the historical schema snapshot alone is not enough.&lt;/p&gt;

&lt;p&gt;Once configuration is ready:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt;. Use a disposable local database for tests and keep server-side secrets out of client code and Git.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms/blob/develop/docs/development/testing.md" rel="noopener noreferrer"&gt;testing guide&lt;/a&gt; covers unit and browser/API verification. For deployment, read the &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms/blob/develop/docs/setup/migrations.md" rel="noopener noreferrer"&gt;migration prerequisites&lt;/a&gt; and &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms/blob/develop/docs/setup/deployment.md" rel="noopener noreferrer"&gt;deployment guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧭 What’s Next
&lt;/h2&gt;

&lt;p&gt;The current roadmap includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Editor assistance.&lt;/li&gt;
&lt;li&gt;An analytics dashboard.&lt;/li&gt;
&lt;li&gt;Scheduled article publication.&lt;/li&gt;
&lt;li&gt;Multi-author collaboration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are planned features. Newsletter dispatch scheduling already exists, but scheduled article publication is a separate capability.&lt;/p&gt;

&lt;p&gt;Check the &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms/blob/develop/docs/features/roadmap.md" rel="noopener noreferrer"&gt;roadmap&lt;/a&gt; and &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms/issues" rel="noopener noreferrer"&gt;GitHub issues&lt;/a&gt; for the evolving status.&lt;/p&gt;

&lt;h2&gt;
  
  
  📦 Open Source — Try It, Fork It, Improve It
&lt;/h2&gt;

&lt;p&gt;The project is MIT-licensed. Use it as a starting point for your own blog or to study how an App Router application handles publishing, permissions, rich text, and background email delivery.&lt;/p&gt;

&lt;p&gt;If you try it, I would love feedback on the writing experience and the parts that are confusing. Bug reports and focused contributions help make it better.&lt;/p&gt;

&lt;p&gt;⭐ &lt;a href="https://github.com/thepracticalengineer/nextjs-blog-cms" rel="noopener noreferrer"&gt;Star or fork the repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy building 👨‍💻&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
