<?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: Alon Cohen</title>
    <description>The latest articles on DEV Community by Alon Cohen (@1b7432841d1a27).</description>
    <link>https://dev.to/1b7432841d1a27</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1632422%2Ff9e2d093-ecbd-4062-a16f-3ddd5ac242ca.jpg</url>
      <title>DEV Community: Alon Cohen</title>
      <link>https://dev.to/1b7432841d1a27</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/1b7432841d1a27"/>
    <language>en</language>
    <item>
      <title>The $19 SaaS: How I built a QR code service with Next.js and Supabase that doesn't need subscriptions</title>
      <dc:creator>Alon Cohen</dc:creator>
      <pubDate>Sun, 29 Mar 2026 16:38:42 +0000</pubDate>
      <link>https://dev.to/1b7432841d1a27/the-19-saas-how-i-built-a-qr-code-service-with-nextjs-and-supabase-that-doesnt-need-3hoi</link>
      <guid>https://dev.to/1b7432841d1a27/the-19-saas-how-i-built-a-qr-code-service-with-nextjs-and-supabase-that-doesnt-need-3hoi</guid>
      <description>&lt;p&gt;Most QR code services charge $60-100/year. I thought I could build one that charges once. Here's how.&lt;/p&gt;

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

&lt;p&gt;I was helping someone set up QR codes for a restaurant menu. They needed dynamic codes — the kind where you can change the destination URL after printing. Every service that offers this charges monthly.&lt;/p&gt;

&lt;p&gt;That seemed wrong. A dynamic QR code is just a redirect with a database row behind it. The infrastructure cost is basically zero. Why does that need a subscription?&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://honestqr.net" rel="noopener noreferrer"&gt;HonestQR&lt;/a&gt; — $19 one-time for dynamic QR codes with scan tracking. Here's the technical rundown of how it works and what I'd do differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; (App Router) on &lt;strong&gt;Vercel&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; for auth, database, and row-level security&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; for one-time payments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostHog&lt;/strong&gt; for product analytics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing exotic. I wanted to ship fast, not build infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  How dynamic QR codes actually work
&lt;/h2&gt;

&lt;p&gt;A dynamic QR code is really just two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A static QR code that points to a short URL on your domain (e.g., &lt;code&gt;honestqr.net/r/abc123&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A server-side redirect that looks up the actual destination in a database&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The "dynamic" part is that you can change the destination without reprinting the QR code. The code itself never changes — only the database row does.&lt;/p&gt;

&lt;p&gt;Here's the simplified redirect logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/r/[slug]/route.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;links&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;destination&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/404&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// Fire and forget — don't block the redirect&lt;/span&gt;
  &lt;span class="nf"&gt;trackScan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;307&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;307&lt;/code&gt; status code matters. A &lt;code&gt;301&lt;/code&gt; tells browsers to cache the redirect permanently, which breaks the whole "dynamic" part. &lt;code&gt;307&lt;/code&gt; means "temporary redirect — check back next time."&lt;/p&gt;

&lt;h2&gt;
  
  
  Scan tracking without slowing down the redirect
&lt;/h2&gt;

&lt;p&gt;The tricky part is analytics. You want to log every scan (device, location, timestamp) without adding latency to the redirect. Nobody wants their QR code to feel slow.&lt;/p&gt;

&lt;p&gt;I went with fire-and-forget: start the database insert but don't &lt;code&gt;await&lt;/code&gt; it before sending the redirect response. The user gets redirected instantly, and the scan event gets logged in the background.&lt;/p&gt;

&lt;p&gt;One gotcha: on Vercel's serverless functions, the execution context can be killed after the response is sent. If your tracking insert takes too long, it might get dropped. Vercel's &lt;code&gt;waitUntil&lt;/code&gt; API handles this — it keeps the function alive until your background work finishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supabase RLS: powerful but you'll get burned
&lt;/h2&gt;

&lt;p&gt;Row Level Security was the right call for multi-tenant data. Each user only sees their own QR codes. The policies look something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"Users can view own links"&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;links&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where I got burned: error messages. When RLS blocks a query, Supabase returns &lt;code&gt;{}&lt;/code&gt; — an empty object, not an error. My early code was showing &lt;code&gt;{}&lt;/code&gt; as a literal error message to users. Not great.&lt;/p&gt;

&lt;p&gt;The fix: check for empty/unexpected responses and show a human-readable fallback. Log the actual response shape to your analytics so you can catch weird edge cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  One-time payments with Stripe
&lt;/h2&gt;

&lt;p&gt;Most Stripe tutorials assume subscriptions. One-time payments are actually simpler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a Checkout Session with &lt;code&gt;mode: 'payment'&lt;/code&gt; instead of &lt;code&gt;mode: 'subscription'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Use a webhook to listen for &lt;code&gt;checkout.session.completed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Update the user's tier in your database when payment succeeds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No subscription lifecycle, no billing portal, no dunning emails. It's refreshing.&lt;/p&gt;

&lt;p&gt;The business tradeoff is real though — no recurring revenue means I need a steady stream of new customers. But the product pitch writes itself: "why are you paying monthly for QR codes?"&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Analytics from day one.&lt;/strong&gt; I added PostHog late and missed early user behavior data. First 100 users are the most valuable to learn from, and I was basically blind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fewer features at launch.&lt;/strong&gt; I built folder organization, bulk generation, and custom branding before I had 10 paying users. Should have launched with just dynamic links + scan counts and added the rest based on what people actually asked for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better onboarding.&lt;/strong&gt; My biggest drop-off is between "created a QR code" and "paid for Pro." The free tier might be too generous — or the upgrade path isn't clear enough. Still figuring this out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, honestly
&lt;/h2&gt;

&lt;p&gt;A few months in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Organic Google traffic growing steadily&lt;/li&gt;
&lt;li&gt;A handful of signups per day&lt;/li&gt;
&lt;li&gt;Conversion from free to paid needs work&lt;/li&gt;
&lt;li&gt;Infrastructure costs: basically the Supabase and Vercel free/hobby tiers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not ramen-profitable yet, but the unit economics work. Each QR code costs me fractions of a cent in storage and compute. A one-time payment model works when your marginal costs are near zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it out
&lt;/h2&gt;

&lt;p&gt;If you want to kick the tires: &lt;a href="https://honestqr.net" rel="noopener noreferrer"&gt;honestqr.net&lt;/a&gt;. Free static QR codes work without an account.&lt;/p&gt;

&lt;p&gt;If you're building something similar with Next.js + Supabase, happy to answer questions in the comments.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
