<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged supabase</title>
<link>https://coderlegion.com/tag/supabase</link>
<description>Powered by Question2Answer</description>
<item>
<title>Extracting a React presence hook from a production event app</title>
<link>https://coderlegion.com/29252/extracting-a-react-presence-hook-from-a-production-event-app</link>
<description>&lt;p&gt;&lt;em&gt;How we turned a battle-tested Supabase Realtime Presence layer into react-supabase-presence&lt;/em&gt;&lt;br&gt;
Real-time “who is in the room” sounds simple until you ship it on phones.&lt;/p&gt;
&lt;p&gt;Tabs freeze. Networks drop. React Strict Mode double-mounts effects. Supabase will often restore the socket for you — but presence track state does not magically re-announce itself. If you do not re-track after resubscribe, other clients still think you are offline.&lt;/p&gt;
&lt;p&gt;We hit that in production while building an event networking product (QR check-in, live roster, ephemeral rooms). The presence logic had to be honest in the UI: not a single boolean “connected,” but a small state machine the interface could show without lying.&lt;/p&gt;
&lt;p&gt;That code is now open source:&lt;br&gt;
npm: react-supabase-presence&lt;br&gt;
GitHub: devhabeeblateef/react-supabase-presence&lt;br&gt;
License: MIT&lt;/p&gt;
&lt;p&gt;The problem we actually had&lt;br&gt;
Supabase Realtime Presence is a good primitive. The gaps show up at the edges:&lt;/p&gt;
&lt;p&gt;Lifecycle — subscribe → track → sync/join/leave. Miss a re-track after reconnect and your user vanishes for everyone else.&lt;br&gt;
Mobile browsers — background tabs can freeze sockets without a clean CLOSED event. Coming back to the tab should re-announce.&lt;br&gt;
UI honesty — “loading” vs “live” vs “trying again” are different states. Collapsing them into one spinner trains users to ignore the product.&lt;/p&gt;
&lt;p&gt;App boundaries — roster, roles, and business logic should not live inside the presence hook. The hook should only answer: who is online, and is the channel healthy?&lt;/p&gt;
&lt;p&gt;We implemented that as a React hook inside the product first, then extracted it so the package has zero knowledge of events, QR codes, or billing.&lt;/p&gt;
&lt;p&gt;What the hook does&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;`import { useEventPresence } from &quot;react-supabase-presence&quot;;
import type { EventPresence, PresenceStatus } from &quot;react-supabase-presence&quot;;
TypeScriptconst { online, status, version } = useEventPresence(
  supabase,   // your browser Supabase client
  channelKey, // stable room / event id
  selfId      // stable presence key for the current user
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Behaviour that mattered in production&lt;/p&gt;
&lt;p&gt;Channel name is derived as presence:${channelKey} with config.presence.key = selfId.&lt;br&gt;
On SUBSCRIBED, the hook tracks { user_id, at }.&lt;br&gt;
On CHANNEL_ERROR / TIMED_OUT / CLOSED, status becomes reconnecting (the client library may still recover underneath).&lt;br&gt;
On visibilitychange → visible, it re-tracks so a returning mobile tab reappears.&lt;/p&gt;
&lt;p&gt;Cleanup removes the channel; no shared global channel name that breaks under Strict Mode remounts.&lt;/p&gt;
&lt;p&gt;You own the SupabaseClient. The library does not create clients, read env vars, or assume your schema.&lt;/p&gt;
&lt;p&gt;Minimal example&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;`&quot;use client&quot;;
import { createClient } from &quot;@supabase/supabase-js&quot;;
import { useEventPresence } from &quot;react-supabase-presence&quot;;

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

export function Room({ roomId, userId }: { roomId: string; userId: string }) {
  const { online, status, version } = useEventPresence(
    supabase,
    roomId,
    userId
  );

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;p&amp;gt;Status: {status}&amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;
        {online.size} online (v{version})
      &amp;lt;/p&amp;gt;
      &amp;lt;ul&amp;gt;
        {[...online].map((id) =&amp;gt; (
          &amp;lt;li key={id}&amp;gt;{id}&amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ul&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Install: npm install react-supabase-presence&lt;br&gt;
Peer dependencies: react (≥18) and @supabase/supabase-js (^2.40).&lt;/p&gt;
&lt;p&gt;Design choices (and non-goals)&lt;br&gt;
In scope for v0.1&lt;/p&gt;
&lt;p&gt;Presence subscribe + track + state aggregation&lt;br&gt;
Explicit connection state for UI&lt;br&gt;
Tab-visible re-track&lt;br&gt;
TypeScript types exported from the package root&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Out of scope (on purpose)&lt;/strong&gt;&lt;br&gt;
Authentication&lt;br&gt;
Authorization / RLS policy design&lt;br&gt;
Handshakes, chat, or matching&lt;br&gt;
Rendering components (dots, avatars, lists)&lt;/p&gt;
&lt;p&gt;Those belong in the product. Keeping them out is what makes the package reusable and reviewable.&lt;br&gt;
The product that motivated this work still uses the published package for its live room roster — same API, one implementation path.&lt;/p&gt;
&lt;p&gt;Why open source this slice&lt;br&gt;
Presence is a sharp edge many Supabase + React apps re-implement poorly. Publishing a small, MIT-licensed hook:&lt;/p&gt;
&lt;p&gt;Documents the reconnect and visibility behaviour in public&lt;br&gt;
Gives others a starting point that is not tied to one startup’s domain model&lt;br&gt;
Forces a clean boundary: if it needs your event table, it does not belong in the library&lt;/p&gt;
&lt;p&gt;If you find bugs or want a multi-tab leader election helper, issues and PRs are welcome on the repo.&lt;/p&gt;
&lt;p&gt;Links&lt;br&gt;
npm: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/react-supabase-presence&quot;&gt;https://www.npmjs.com/package/react-supabase-presence&lt;/a&gt;&lt;br&gt;
Source: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/devhabeeblateef/react-supabase-presence&quot;&gt;https://github.com/devhabeeblateef/react-supabase-presence&lt;/a&gt;&lt;br&gt;
License: MIT&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29252/extracting-a-react-presence-hook-from-a-production-event-app</guid>
<pubDate>Wed, 30 Sep 2026 14:10:55 +0000</pubDate>
</item>
<item>
<title>Multi-Tenant Architecture in Next.js 16 (2026)</title>
<link>https://coderlegion.com/27782/multi-tenant-architecture-in-next-js-16-2026</link>
<description>&lt;p&gt;Every SaaS product hits the same wall eventually: you built it for one company, and now you need it to work for a hundred — each with their own users, their own data, and their own little corner of the app, like &lt;code&gt;acme.yourapp.com&lt;/code&gt;. Bolt that on badly and you get the nightmare scenario every backend developer fears: one bug in a &lt;code&gt;WHERE&lt;/code&gt; clause, and Company A's customer list shows up in Company B's dashboard.&lt;/p&gt;
&lt;p&gt;That's the entire problem &lt;strong&gt;multi-tenant architecture&lt;/strong&gt; solves. It's not one trick — it's a set of decisions about routing, data isolation, and access control that, done right, make &quot;which company does this user belong to?&quot; a question your app can never get wrong, even if a developer forgets to ask it.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll have a working multi-tenant Next.js 16 app: subdomain-based routing through &lt;code&gt;proxy.ts&lt;/code&gt;, a tenant data model backed by Postgres Row Level Security so isolation is enforced by the database — not just your application code — and a path to deploying it on Vercel with wildcard domains.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;quick-answer-what-is-multi-tenant-architecture-&quot;&gt;Quick Answer: What Is Multi-Tenant Architecture?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Multi-tenant architecture&lt;/strong&gt; is a software design where a single application instance and (usually) a single database serve multiple independent customers — called &lt;strong&gt;tenants&lt;/strong&gt; — while keeping each tenant's data, users, and configuration completely isolated from every other tenant. Instead of deploying a separate copy of your app per customer, one codebase and one deployment serve everyone, and the app itself decides which tenant a request belongs to and what data that tenant is allowed to see.&lt;/p&gt;
&lt;p&gt;Think of it like an apartment building: one building (your app), many separate units (tenants), each with its own locked door (data isolation) — but the plumbing, the elevator, and the foundation (your codebase and infrastructure) are shared.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;single-tenant-vs-multi-tenant-the-difference-that-matters&quot;&gt;Single-Tenant vs. Multi-Tenant: The Difference That Matters&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Single-Tenant &lt;/th&gt;&lt;th&gt;Multi-Tenant&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Deployment &lt;/td&gt;&lt;td&gt;One app + one database per customer &lt;/td&gt;&lt;td&gt;One app (usually one database) for all customers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Infrastructure cost &lt;/td&gt;&lt;td&gt;Scales linearly with customers &lt;/td&gt;&lt;td&gt;Shared, scales with usage not customer count&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Data isolation &lt;/td&gt;&lt;td&gt;Physical (separate databases) &lt;/td&gt;&lt;td&gt;Logical (enforced in code and/or the database)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Updates/bug fixes &lt;/td&gt;&lt;td&gt;Deploy to every customer separately &lt;/td&gt;&lt;td&gt;Deploy once, everyone gets it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Customization per customer &lt;/td&gt;&lt;td&gt;Easy — each customer has an isolated deployment/configuration &lt;/td&gt;&lt;td&gt;Harder — needs a plan/feature-flag system&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Best for &lt;/td&gt;&lt;td&gt;Enterprise clients needing full data isolation or compliance &lt;/td&gt;&lt;td&gt;SaaS products with many small-to-mid customers&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Many collaboration and SaaS platforms expose workspace/organization concepts that follow the multi-tenant model. You don't get your own copy of Slack's servers when you sign up; you get a &lt;strong&gt;workspace&lt;/strong&gt;, which is just a tenant inside their shared infrastructure.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; &quot;Tenant&quot; is deliberately generic — in your app it might be called a workspace, organization, team, company, or account. The underlying pattern is identical regardless of what you call it in your UI.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-is-tricky&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;Multi-tenancy isn't hard because the concept is complicated — it's hard because &lt;strong&gt;the cost of getting it wrong is severe&lt;/strong&gt; (a data leak between customers), and there are genuinely several valid ways to build it, each with real tradeoffs. Before writing any code, you need to answer two separate questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;How do I identify which tenant a request belongs to?&lt;/strong&gt; (subdomain, custom domain, or URL path)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How do I keep each tenant's data isolated?&lt;/strong&gt; (separate databases, separate schemas, or one shared table set with a &lt;code&gt;tenant_id&lt;/code&gt; column)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Get the first one wrong and tenants can't reach the right version of your app. Get the second one wrong and — worst case — tenants can see each other's data. We'll cover both, and this guide focuses on the combination most SaaS products actually ship with: &lt;strong&gt;subdomain-based routing&lt;/strong&gt; plus a &lt;strong&gt;shared database with row-level isolation&lt;/strong&gt;, because it's the cheapest to run and the fastest to build on, while still giving you database-enforced security.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-three-data-isolation-models&quot;&gt;The Three Data Isolation Models&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Model &lt;/th&gt;&lt;th&gt;How it works &lt;/th&gt;&lt;th&gt;Isolation strength &lt;/th&gt;&lt;th&gt;Cost &amp;amp; complexity&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Database-per-tenant&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Every tenant gets a fully separate database &lt;/td&gt;&lt;td&gt;Strongest — physical separation &lt;/td&gt;&lt;td&gt;Highest — migrations, backups, and connections multiply per tenant&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Schema-per-tenant&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;One database, one Postgres schema per tenant &lt;/td&gt;&lt;td&gt;Strong — separate tables per tenant &lt;/td&gt;&lt;td&gt;Medium — still need to manage N schemas, but one database to back up&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Shared tables + &lt;code&gt;tenant_id&lt;/code&gt;&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;One database, one set of tables, every row tagged with a &lt;code&gt;tenant_id&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Depends entirely on enforcement — weak if only in app code, strong with Postgres Row Level Security &lt;/td&gt;&lt;td&gt;Lowest — one schema, one migration, scales to thousands of tenants easily&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For most SaaS products — and everything in this guide — &lt;strong&gt;shared tables with a &lt;code&gt;tenant_id&lt;/code&gt; column, enforced by Postgres Row Level Security (RLS)&lt;/strong&gt;, is the right default. It's the cheapest to operate, the easiest to migrate, and — critically — RLS means isolation is enforced by the database itself, not just by remembering to add &lt;code&gt;.eq(&quot;tenant_id&quot;, ...)&lt;/code&gt; to every query. Reach for schema-per-tenant or database-per-tenant only when a specific customer has a compliance requirement (like data residency) that a shared database genuinely can't satisfy.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Related: Supabase RLS Policy Generator&lt;/a&gt; — generates the exact kind of tenant-scoped policies this guide writes by hand, in seconds.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;three-ways-to-identify-a-tenant&quot;&gt;Three Ways to Identify a Tenant&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Strategy &lt;/th&gt;&lt;th&gt;Example URL &lt;/th&gt;&lt;th&gt;Pros &lt;/th&gt;&lt;th&gt;Cons&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Subdomain&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;acme.yourapp.com&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Clean URLs, easy to brand, works great with wildcard SSL &lt;/td&gt;&lt;td&gt;Needs wildcard DNS + nameserver setup&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Custom domain&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;app.acme.com&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Best for enterprise/white-label, tenant owns their domain &lt;/td&gt;&lt;td&gt;Most setup work — DNS verification, SSL provisioning per domain&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Path-based&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;yourapp.com/acme/dashboard&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Zero DNS config, works anywhere immediately &lt;/td&gt;&lt;td&gt;Tenant is visible and editable in every URL, no per-tenant branding&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This guide builds &lt;strong&gt;subdomain-based&lt;/strong&gt; routing, since it's the sweet spot most SaaS products land on — better UX and branding than path-based, far less setup work than per-tenant custom domains. We'll touch on custom domains near the end for when you're ready to offer that too.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-we-re-building&quot;&gt;What We're Building&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supabase (Postgres)&lt;/strong&gt; for the database, using Row Level Security for tenant isolation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the (minimal) UI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By the end, visiting &lt;code&gt;acme.localhost:3000&lt;/code&gt; will resolve to Acme's tenant context, &lt;code&gt;acme.yourapp.com&lt;/code&gt; will do the same in production, and every database query for tenant data will be protected by RLS — so even a bug in your own code can't leak one tenant's rows to another.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;tenant1.yourapp.com/dashboard
        ↓
   proxy.ts resolves &quot;tenant1&quot; → looks up tenant in DB
        ↓
   Tenant ID attached to the request as a header
        ↓
   Server Component reads the header, fetches tenant-scoped data
        ↓
   Postgres RLS double-checks: is this row actually this tenant's?
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-project-setup&quot;&gt;Step 1: Project Setup&lt;/h3&gt;
&lt;p&gt;If you're starting fresh:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx create-next-app@latest multi-tenant-app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Choose TypeScript, Tailwind CSS, App Router, and the &lt;code&gt;src/&lt;/code&gt; directory if you prefer it (this guide omits &lt;code&gt;src/&lt;/code&gt; for shorter paths). You'll also need Supabase:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @supabase/supabase-js @supabase/ssr
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add your environment variables:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key

# The root domain your app is served from. In development this keeps
# its port (localhost:3000); in production it's your bare domain.
NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; must never be prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;. It bypasses every Row Level Security policy in your database — treat it exactly like a database root password, server-only, never shipped to the browser.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;resolve-the-tenant-in-proxy&quot;&gt;Step 2: Resolve the Tenant in `proxy.ts`&lt;/h2&gt;
&lt;p&gt;In Next.js 16, the file previously called &lt;code&gt;middleware.ts&lt;/code&gt; was renamed to &lt;strong&gt;&lt;code&gt;proxy.ts&lt;/code&gt;&lt;/strong&gt; — same job, same file conventions, just a new name and a change in default runtime (it now runs on the Node.js runtime by default, which matters here since we're making a database call on every request).&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Think of &lt;code&gt;proxy.ts&lt;/code&gt; like a receptionist at the front desk of your building.&lt;/strong&gt; Every visitor (request) talks to the receptionist first. The receptionist checks which company (tenant) they're visiting based on which door they walked through (the subdomain), confirms that company actually exists in the building directory, and then tells the elevator (your app) exactly where to send them — before the visitor ever reaches an office.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;First, a small server-only lookup function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/service-client.ts
import &quot;server-only&quot;;
import { createClient } from &quot;@supabase/supabase-js&quot;;

// Service-role client — bypasses RLS, has no user session attached.
// Only ever call this for trusted, server-only reads like tenant lookups.
// Never import this into a Client Component.
export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/tenant/get-tenant-by-subdomain.ts
import &quot;server-only&quot;;
import { supabaseAdmin } from &quot;@/lib/supabase/service-client&quot;;

export type TenantSummary = {
  id: string;
  slug: string;
};

export async function getTenantBySubdomain(
  subdomain: string
): Promise&amp;lt;TenantSummary | null&amp;gt; {
  const { data, error } = await supabaseAdmin
    .from(&quot;tenants&quot;)
    .select(&quot;id, slug&quot;)
    .eq(&quot;slug&quot;, subdomain)
    .maybeSingle();

  if (error || !data) return null;
  return data;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you already have Supabase auth wired up (see our &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/supabase-auth-vs-firebase-auth&quot;&gt;Supabase Auth vs Firebase Auth&lt;/a&gt; guide if not), you likely already have these two files for keeping the auth session refreshed on every request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/proxy.ts
import { createServerClient } from &quot;@supabase/ssr&quot;;
import { NextResponse, type NextRequest } from &quot;next/server&quot;;

export async function updateSession(request: NextRequest) {
    let supabaseResponse = NextResponse.next({ request });

    const supabase = createServerClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
        {
            cookies: {
                getAll() {
                    return request.cookies.getAll();
                },

                setAll(cookiesToSet, headers) {
                    cookiesToSet.forEach(({ name, value }) =&amp;gt;
                        request.cookies.set(name, value)
                    );

                    supabaseResponse = NextResponse.next({
                        request,
                    });

                    cookiesToSet.forEach(({ name, value, options }) =&amp;gt;
                        supabaseResponse.cookies.set(name, value, options)
                    );
                    Object.entries(headers).forEach(([key, value]) =&amp;gt;
                        supabaseResponse.headers.set(key, value)
                    );
                },
            },
        }
    );

    await supabase.auth.getClaims();

    return supabaseResponse;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/server.ts
import { createServerClient } from &quot;@supabase/ssr&quot;;
import { cookies } from &quot;next/headers&quot;;

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },

        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =&amp;gt;
              cookieStore.set(name, value, options)
            );
          } catch {
            // Ignore if called from Server Component
          }
        },
      },
    }
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Don't skip this if you have authentication.&lt;/strong&gt; Tenant resolution and session refresh are two separate concerns, but they both have to happen in the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;proxy.ts&lt;/code&gt; run. If you only resolve the tenant and never call &lt;code&gt;updateSession&lt;/code&gt;, Supabase's access token is never refreshed on the server, and signed-in users get logged out once it expires — even though nothing about tenant routing looks broken.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now the proxy itself, composing both:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// proxy.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { updateSession } from &quot;@/lib/supabase/proxy&quot;;
import { getTenantBySubdomain } from &quot;@/lib/tenant/get-tenant-by-subdomain&quot;;

// Keep the port for building an absolute redirect URL later; strip it
// only when comparing hostnames.
const ROOT_DOMAIN_ENV = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? &quot;localhost:3000&quot;;
const ROOT_DOMAIN = ROOT_DOMAIN_ENV.split(&quot;:&quot;)[0];

// Subdomains that belong to your main app, not a tenant.
const RESERVED_SUBDOMAINS = new Set([&quot;www&quot;, &quot;app&quot;, &quot;admin&quot;, &quot;api&quot;]);

export async function proxy(request: NextRequest) {
  // Refresh the Supabase auth session first. This may rewrite Set-Cookie
  // headers if the access token needed rotating — we copy those onto
  // whatever response we ultimately return below.
  const supabaseResponse = await updateSession(request);

  const host = (request.headers.get(&quot;host&quot;) ?? &quot;&quot;).split(&quot;:&quot;)[0];
  const subdomain = getSubdomain(host);

  // No subdomain, or a reserved one — this is the marketing site or the
  // main app shell, not a tenant. Nothing tenant-related to attach.
  if (!subdomain || RESERVED_SUBDOMAINS.has(subdomain)) {
    return supabaseResponse;
  }

  const tenant = await getTenantBySubdomain(subdomain);

  if (!tenant) {
    return redirectToTenantNotFound(request, subdomain);
  }

  // Strip any inbound tenant headers so a client can never forge them —
  // only this proxy is allowed to set x-tenant-*.
  const requestHeaders = new Headers(request.headers);
  requestHeaders.delete(&quot;x-tenant-id&quot;);
  requestHeaders.delete(&quot;x-tenant-slug&quot;);
  requestHeaders.set(&quot;x-tenant-id&quot;, tenant.id);
  requestHeaders.set(&quot;x-tenant-slug&quot;, tenant.slug);

  // Rebuild the response so the tenant headers ride along on the
  // *request* (this is what makes them readable via headers() in a
  // Server Component), then carry over any refreshed auth cookies.
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  supabaseResponse.cookies.getAll().forEach((cookie) =&amp;gt; {
    response.cookies.set(cookie);
  });

  return response;
}

// Redirects to the ROOT domain, not the unknown tenant's subdomain —
// redirecting back to the same bad host would just re-trigger this
// same lookup and loop forever.
//
// Built as a brand-new URL rather than mutating a clone of request.url.
// Cloning request.url first (new URL(request.url)) means the object
// starts out with the tenant subdomain already baked into it, and
// reassigning .host on that object is not reliable for fully replacing
// an existing hostname — in practice it can end up only changing the
// port and leaving the old subdomain in place (e.g. producing
// &quot;unknowndomain.localhost:3000&quot; instead of &quot;localhost:3000&quot;), which
// silently recreates the exact loop this function exists to prevent.
// Constructing the URL from a plain template string sidesteps that
// entirely, since there's no stale hostname left for anything to retain.
function redirectToTenantNotFound(request: NextRequest, subdomain: string) {
  const protocol = request.nextUrl.protocol; // &quot;http:&quot; or &quot;https:&quot;
  const notFoundUrl = new URL(`${protocol}//${ROOT_DOMAIN_ENV}/tenant-not-found`);
  notFoundUrl.searchParams.set(&quot;tenant&quot;, subdomain);
  return NextResponse.redirect(notFoundUrl);
}

function getSubdomain(host: string): string | null {
  if (host === ROOT_DOMAIN || host === `www.${ROOT_DOMAIN}`) return null;
  if (!host.endsWith(`.${ROOT_DOMAIN}`)) return null;

  return host.slice(0, -(ROOT_DOMAIN.length + 1));
}

export const config = {
  matcher: [&quot;/((?!_next/static|_next/image|favicon.ico).*)&quot;],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;updateSession(request)&lt;/code&gt; runs first and unconditionally — auth refresh shouldn't depend on whether the host happens to be a tenant subdomain.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;redirectToTenantNotFound()&lt;/code&gt; builds a brand-new URL pointed at &lt;code&gt;ROOT_DOMAIN_ENV&lt;/code&gt; (your real app domain) instead of the unresolved tenant subdomain. The next time proxy runs against that new URL, &lt;code&gt;getSubdomain()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; for the root domain and the request just passes through — no loop. This has to be a &lt;em&gt;fresh&lt;/em&gt; URL, not a mutated clone of the incoming request's URL — cloning &lt;code&gt;request.url&lt;/code&gt; first means the tenant subdomain is already baked into the object, and reassigning &lt;code&gt;.host&lt;/code&gt; on it is not reliable for fully replacing that hostname (in practice it can end up changing only the port while the old subdomain silently survives, e.g. &lt;code&gt;unknowndomain.localhost:3000&lt;/code&gt; instead of &lt;code&gt;localhost:3000&lt;/code&gt;), which just recreates the same loop this function exists to prevent.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;?tenant=&lt;/code&gt; query param survives the redirect, so your &lt;code&gt;/tenant-not-found&lt;/code&gt; page can show &lt;em&gt;which&lt;/em&gt; subdomain didn't resolve.&lt;/li&gt;
&lt;li&gt;We only build &lt;code&gt;requestHeaders&lt;/code&gt; (and delete any inbound &lt;code&gt;x-tenant-*&lt;/code&gt; values) once we know a real tenant was found — there's nothing to strip or attach on the reserved/no-subdomain path, since &lt;code&gt;supabaseResponse&lt;/code&gt; is returned as-is.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;supabaseResponse.cookies.getAll()&lt;/code&gt; returns whatever refreshed auth cookies Supabase's client queued up; copying them onto the new tenant-aware response means neither concern silently overwrites the other.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A minimal page for the redirect target:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/tenant-not-found/page.tsx
interface PageProps {
  searchParams: Promise&amp;lt;{ tenant?: string }&amp;gt;;
}

export default async function TenantNotFoundPage({ searchParams }: PageProps) {
  const { tenant } = await searchParams;

  return (
    &amp;lt;div className=&quot;mx-auto max-w-md space-y-3 p-8 text-center&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-2xl font-semibold&quot;&amp;gt;Workspace not found&amp;lt;/h1&amp;gt;
      &amp;lt;p className=&quot;text-sm text-muted-foreground&quot;&amp;gt;
        {tenant
          ? `We couldn't find a workspace at &quot;${tenant}&quot;.`
          : &quot;That workspace doesn't exist.&quot;}
      &amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Running your tenant lookup on the Edge runtime by explicitly setting &lt;code&gt;runtime: 'edge'&lt;/code&gt;. Don't — &lt;code&gt;proxy.ts&lt;/code&gt; in Next.js 16 runs on the Node.js runtime by default (and throws if you try to override it), which is exactly what you want here, since a database client typically needs Node.js APIs that aren't available on the Edge runtime.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; For a small application, this direct indexed lookup is straightforward. At higher traffic, cache the subdomain → tenant mapping in Redis/KV/Edge Config or another low-latency store so proxy doesn't hit Postgres on every request.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-testing-subdomains-locally&quot;&gt;Step 3: Testing Subdomains Locally&lt;/h3&gt;
&lt;p&gt;You don't need to edit your &lt;code&gt;/etc/hosts&lt;/code&gt; file for this. Modern browsers (Chrome, Firefox, Safari) automatically resolve any &lt;code&gt;*.localhost&lt;/code&gt; address to &lt;code&gt;127.0.0.1&lt;/code&gt; — it's a reserved TLD, not a Next.js feature. So once your dev server is running:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Visiting &lt;code&gt;http://acme.localhost:3000&lt;/code&gt; in your browser just works, as long as &lt;code&gt;NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000&lt;/code&gt; is set in &lt;code&gt;.env.local&lt;/code&gt; and a tenant with &lt;code&gt;slug = 'acme'&lt;/code&gt; exists in your database.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If a subdomain isn't resolving locally, it's almost always a browser/network quirk (some corporate VPNs or older browsers don't support &lt;code&gt;.localhost&lt;/code&gt; resolution) rather than a bug in your code — try a different browser before debugging &lt;code&gt;proxy.ts&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-model-tenants-and-isolation-in-postgres&quot;&gt;Step 4: Model Tenants and Isolation in Postgres&lt;/h3&gt;
&lt;p&gt;This is the part that actually keeps tenants' data apart. Run this in the Supabase SQL editor.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Core tenant table. The slug doubles as a DNS label (acme.yourapp.com),
-- so its constraint is stricter than a generic &quot;url-safe string&quot; check:
-- no leading/trailing hyphens, and capped at 63 characters (the DNS
-- label limit), on top of lowercase alphanumerics and hyphens.
create table public.tenants (
  id uuid primary key default gen_random_uuid(),
  slug text unique not null check (
    char_length(slug) between 1 and 63
    and slug ~ '^[a-z0-9-]+$'
    and slug !~ '(^-|-$)'
  ),
  name text not null,
  plan text not null default 'free',
  created_at timestamptz not null default now()
);

-- Who belongs to which tenant, and with what role
create table public.memberships (
  user_id uuid not null references auth.users(id) on delete cascade,
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  role text not null default 'member' check (role in ('owner', 'admin', 'member')),
  created_at timestamptz not null default now(),
  primary key (user_id, tenant_id)
);

-- An example tenant-scoped resource — every real table in your app
-- (invoices, documents, tasks, whatever) follows this same shape.
create table public.projects (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  name text not null,
  created_at timestamptz not null default now()
);

-- The composite primary key (user_id, tenant_id) already gives Postgres
-- a btree index usable for &quot;where user_id = ?&quot; lookups, since a
-- multi-column index supports equality search on a leading prefix of
-- its columns — a separate single-column index on user_id would just
-- be a redundant duplicate. What that composite index can't serve
-- efficiently is &quot;where tenant_id = ?&quot; on its own, since tenant_id
-- isn't the leading column — that's the index actually worth adding.
create index idx_memberships_tenant_id on public.memberships (tenant_id);
create index idx_projects_tenant_id on public.projects (tenant_id);

alter table public.tenants enable row level security;
alter table public.memberships enable row level security;
alter table public.projects enable row level security;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;tenants&lt;/code&gt; is your list of customers. &lt;code&gt;memberships&lt;/code&gt; is the join table linking Supabase's built-in &lt;code&gt;auth.users&lt;/code&gt; to tenants — one user can belong to multiple tenants (think: a consultant working with several client workspaces). &lt;code&gt;projects&lt;/code&gt; is a stand-in for any real, tenant-owned data table — invoices, documents, tasks, whatever your app actually stores.&lt;/p&gt;
&lt;h4 id=&quot;write-the-rls-policies&quot;&gt;Write the RLS Policies&lt;/h4&gt;
&lt;p&gt;A &lt;code&gt;tenant_id&lt;/code&gt; column on a table means nothing on its own — it's just data. &lt;strong&gt;Row Level Security is what turns that column into an actual security boundary&lt;/strong&gt;, enforced by Postgres itself rather than trusted to every query you (or a future teammate) ever write.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Security-definer helper: returns every tenant_id the current user
-- belongs to. Wrapping it this way avoids the classic recursive-RLS
-- trap (a policy on &quot;projects&quot; querying &quot;memberships&quot;, which has its
-- own RLS that would otherwise re-trigger the same check).
--
-- It lives in a dedicated &quot;private&quot; schema rather than &quot;public&quot; —
-- Supabase's Data API (PostgREST) only exposes schemas you've explicitly
-- opted into (public by default), so putting security-definer functions
-- in &quot;private&quot; keeps them uncallable directly over the REST/RPC API.
-- A security-definer function sitting in &quot;public&quot; is callable by anyone
-- who can reach your API, running with the *creator's* privileges —
-- that's a real privilege-escalation surface if it isn't locked down.
create schema if not exists private;

create or replace function private.user_tenant_ids()
returns setof uuid
language sql
security definer
stable
set search_path = ''
as \(
  select tenant_id
  from public.memberships
  where user_id = (select auth.uid())
\);

-- Postgres grants EXECUTE on every new function to PUBLIC by default —
-- revoke it explicitly, then grant only to the role that actually needs it.
revoke execute on function private.user_tenant_ids() from public;
revoke execute on function private.user_tenant_ids() from anon;
grant usage on schema private to authenticated;
grant execute on function private.user_tenant_ids() to authenticated;

-- Members can see their own membership rows
create policy &quot;memberships_select_own&quot;
on public.memberships
for select
to authenticated
using ( user_id = (select auth.uid()) );

-- Members can see the tenants they belong to
create policy &quot;tenants_select_member&quot;
on public.tenants
for select
to authenticated
using ( id in (select private.user_tenant_ids()) );

-- Members can only see, create, edit, or delete projects that belong
-- to a tenant they're actually part of
create policy &quot;projects_select_tenant_member&quot;
on public.projects
for select
to authenticated
using ( tenant_id in (select private.user_tenant_ids()) );

create policy &quot;projects_insert_tenant_member&quot;
on public.projects
for insert
to authenticated
with check ( tenant_id in (select private.user_tenant_ids()) );

create policy &quot;projects_update_tenant_member&quot;
on public.projects
for update
to authenticated
using ( tenant_id in (select private.user_tenant_ids()) )
with check ( tenant_id in (select private.user_tenant_ids()) );

create policy &quot;projects_delete_tenant_member&quot;
on public.projects
for delete
to authenticated
using ( tenant_id in (select private.user_tenant_ids()) );
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;user_tenant_ids()&lt;/code&gt; is a &lt;code&gt;security definer&lt;/code&gt; function — it runs with elevated privileges internally, which lets it read &lt;code&gt;memberships&lt;/code&gt; (bypassing that table's own RLS just for this lookup) without creating a circular dependency. &lt;code&gt;set search_path = ''&lt;/code&gt; forces every identifier inside the function body to be fully schema-qualified (notice &lt;code&gt;public.memberships&lt;/code&gt;, not just &lt;code&gt;memberships&lt;/code&gt;) — without this, a malicious or accidental object created earlier in the search path could get resolved instead of the table you actually meant, which is exactly the class of attack Supabase's own linter flags as &quot;Function Search Path Mutable.&quot; The &lt;code&gt;revoke&lt;/code&gt;/&lt;code&gt;grant&lt;/code&gt; pair closes the other half of the gap: by default Postgres lets &lt;strong&gt;any&lt;/strong&gt; role call a newly created function, so without the explicit revoke, &lt;code&gt;anon&lt;/code&gt; (unauthenticated requests) could call it too.&lt;/p&gt;
&lt;p&gt;Every policy on &lt;code&gt;projects&lt;/code&gt; then just asks &quot;is this row's &lt;code&gt;tenant_id&lt;/code&gt; one this user belongs to?&quot; Wrapping &lt;code&gt;auth.uid()&lt;/code&gt; in &lt;code&gt;(select ...)&lt;/code&gt; lets Postgres cache the result once per statement instead of re-evaluating it for every row — a meaningful performance win once your tables have real data in them.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Notice there's no policy granting &lt;code&gt;using (true)&lt;/code&gt; anywhere. Every single operation is explicitly scoped to tenant membership — this is deliberate. A missing policy means Postgres denies access by default, which is exactly the fail-safe direction you want security to fail in.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Putting a &lt;code&gt;security definer&lt;/code&gt; helper function in the &lt;code&gt;public&lt;/code&gt; schema &quot;because that's where everything else is.&quot; If a schema is exposed through Supabase's Data API settings (public is, by default), every function in it is a potential RPC endpoint — &lt;code&gt;private&lt;/code&gt; (or any schema you deliberately keep out of the exposed-schemas list) is the safer default for anything that runs with elevated privileges.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/supabase-auth-vs-firebase-auth&quot;&gt;Related: Supabase Auth vs Firebase Auth in 2026&lt;/a&gt; if you haven't wired up authentication yet.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-read-the-tenant-in-server-components&quot;&gt;Step 5: Read the Tenant in Server Components&lt;/h3&gt;
&lt;p&gt;Now that &lt;code&gt;proxy.ts&lt;/code&gt; attaches &lt;code&gt;x-tenant-id&lt;/code&gt; to every request, build a small &lt;strong&gt;Data Access Layer&lt;/strong&gt; function that reads it — this is the single place the rest of your app goes to ask &quot;which tenant is this?&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/tenant/get-current-tenant.ts
import &quot;server-only&quot;;
import { cache } from &quot;react&quot;;
import { headers } from &quot;next/headers&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { supabaseAdmin } from &quot;@/lib/supabase/service-client&quot;;

export type Tenant = {
  id: string;
  slug: string;
  name: string;
  plan: string;
};

export const getCurrentTenant = cache(async (): Promise&amp;lt;Tenant&amp;gt; =&amp;gt; {
  const headersList = await headers();
  const tenantId = headersList.get(&quot;x-tenant-id&quot;);

  if (!tenantId) {
    // No tenant header means this request never went through a tenant
    // subdomain — proxy.ts only sets it for tenant*.yourapp.com traffic.
    redirect(&quot;/&quot;);
  }

  const { data: tenant, error } = await supabaseAdmin
    .from(&quot;tenants&quot;)
    .select(&quot;id, slug, name, plan&quot;)
    .eq(&quot;id&quot;, tenantId)
    .maybeSingle();

  if (error || !tenant) {
    redirect(&quot;/tenant-not-found&quot;);
  }

  return tenant;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;headers()&lt;/code&gt; is async in Next.js 16, so it has to be awaited. Wrapping the whole function in React's &lt;code&gt;cache()&lt;/code&gt; means no matter how many components on the same page call &lt;code&gt;getCurrentTenant()&lt;/code&gt;, the actual header read and database lookup only run once per request.&lt;/p&gt;
&lt;p&gt;Next, a helper that also confirms the signed-in user actually belongs to this tenant — not just that the tenant exists:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/tenant/require-tenant-member.ts
import &quot;server-only&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { createClient } from &quot;@/lib/supabase/server&quot;;
import { getCurrentTenant } from &quot;./get-current-tenant&quot;;

export async function requireTenantMember() {
  const tenant = await getCurrentTenant();
  const supabase = await createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    redirect(`/login?tenant=${tenant.slug}`);
  }

  // This query runs through the cookie-based client, so RLS applies —
  // it can only ever return a row for the signed-in user themselves.
  const { data: membership } = await supabase
    .from(&quot;memberships&quot;)
    .select(&quot;role&quot;)
    .eq(&quot;tenant_id&quot;, tenant.id)
    .eq(&quot;user_id&quot;, user.id)
    .maybeSingle();

  if (!membership) {
    redirect(&quot;/not-a-member&quot;);
  }

  return { tenant, user, role: membership.role };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use it in a page:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
import { requireTenantMember } from &quot;@/lib/tenant/require-tenant-member&quot;;
import { createClient } from &quot;@/lib/supabase/server&quot;;

export default async function DashboardPage() {
  const { tenant, user } = await requireTenantMember();
  const supabase = await createClient();

  // The explicit tenant_id filter selects the active tenant represented
  // by this request. RLS provides the database-level safety net that
  // prevents the signed-in user from accessing tenants they don't belong to.
  const { data: projects } = await supabase
    .from(&quot;projects&quot;)
    .select(&quot;id, name, created_at&quot;)
    .eq(&quot;tenant_id&quot;, tenant.id)
    .order(&quot;created_at&quot;, { ascending: false });

  return (
    &amp;lt;div className=&quot;mx-auto max-w-2xl space-y-6 p-8&quot;&amp;gt;
      &amp;lt;div&amp;gt;
        &amp;lt;h1 className=&quot;text-2xl font-semibold&quot;&amp;gt;{tenant.name}&amp;lt;/h1&amp;gt;
        &amp;lt;p className=&quot;text-sm text-muted-foreground&quot;&amp;gt;Signed in as {user.email}&amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;ul className=&quot;space-y-2&quot;&amp;gt;
        {projects?.map((project) =&amp;gt; (
          &amp;lt;li key={project.id} className=&quot;rounded-lg border p-3&quot;&amp;gt;
            {project.name}
          &amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ul&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; notice the &lt;code&gt;.eq(&quot;tenant_id&quot;, tenant.id)&lt;/code&gt; filter is still there in the app code, even though RLS would block cross-tenant rows either way. This is &lt;strong&gt;defense in depth&lt;/strong&gt; — the query filter makes the intent obvious to any developer reading the code, and RLS is the safety net that catches the case where someone forgets it.&lt;/p&gt;
&lt;h4 id=&quot;writing-tenant-scoped-data-with-a-server-action&quot;&gt;Writing Tenant-Scoped Data with a Server Action&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/create-project.ts
&quot;use server&quot;;

import { revalidatePath } from &quot;next/cache&quot;;
import { createClient } from &quot;@/lib/supabase/server&quot;;
import { requireTenantMember } from &quot;@/lib/tenant/require-tenant-member&quot;;

export async function createProject(formData: FormData) {
  const { tenant } = await requireTenantMember();
  const supabase = await createClient();

  const name = (formData.get(&quot;name&quot;) as string | null)?.trim();

  if (!name) {
    return { error: &quot;Project name is required.&quot; };
  }

  const { error } = await supabase
    .from(&quot;projects&quot;)
    .insert({ name, tenant_id: tenant.id });

  if (error) {
    return { error: error.message };
  }

  revalidatePath(&quot;/dashboard&quot;);
  return { success: true };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;requireTenantMember()&lt;/code&gt; runs first, so an unauthenticated or non-member request never reaches the database at all. Even so, the &lt;code&gt;insert&lt;/code&gt; explicitly sets &lt;code&gt;tenant_id: tenant.id&lt;/code&gt; rather than trusting a value from the form — never let tenant identity come from client-submitted data, always derive it server-side from the resolved tenant context.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Accepting a &lt;code&gt;tenantId&lt;/code&gt; field from &lt;code&gt;formData&lt;/code&gt; or a request body and trusting it. The tenant a user is allowed to write to should always come from &lt;code&gt;getCurrentTenant()&lt;/code&gt; / the resolved request context — never from anything the client sent.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Related: Role-Based Access Control (RBAC) in Next.js 16&lt;/a&gt; pairs naturally with this — once you have tenants, you'll usually want owner/admin/member permissions &lt;em&gt;within&lt;/em&gt; each tenant too, not just membership.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-deploy-wildcard-domains-on-vercel&quot;&gt;Step 6: Deploy Wildcard Domains on Vercel&lt;/h3&gt;
&lt;p&gt;To get &lt;code&gt;acme.yourapp.com&lt;/code&gt;, &lt;code&gt;beta-co.yourapp.com&lt;/code&gt;, and every future tenant's subdomain working automatically in production, you need a &lt;strong&gt;wildcard domain&lt;/strong&gt; pointed at your Vercel project.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Point your domain to Vercel's nameservers&lt;/strong&gt; (&lt;code&gt;ns1.vercel-dns.com&lt;/code&gt; and &lt;code&gt;ns2.vercel-dns.com&lt;/code&gt;). Vercel needs to control your DNS to automatically issue SSL certificates for each new subdomain.&lt;/li&gt;
&lt;li&gt;In your Vercel project's &lt;strong&gt;Domains&lt;/strong&gt; settings, add the apex domain: &lt;code&gt;yourapp.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add a wildcard domain: &lt;code&gt;*.yourapp.com&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once that's done, any single-label subdomain — &lt;code&gt;acme.yourapp.com&lt;/code&gt;, &lt;code&gt;beta-co.yourapp.com&lt;/code&gt; — automatically resolves to your deployment, with Vercel issuing a certificate for each one on the fly. No per-tenant DNS work required on your end.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ A wildcard certificate for &lt;code&gt;*.yourapp.com&lt;/code&gt; covers exactly &lt;strong&gt;one&lt;/strong&gt; DNS label — it matches &lt;code&gt;acme.yourapp.com&lt;/code&gt; but not a deeper subdomain like &lt;code&gt;docs.acme.yourapp.com&lt;/code&gt;. That kind of nested subdomain would need its own certificate and DNS entry, and this guide's &lt;code&gt;getSubdomain()&lt;/code&gt; parser doesn't handle it either (it would slice &lt;code&gt;docs.acme.yourapp.com&lt;/code&gt; down to the single string &lt;code&gt;&quot;docs.acme&quot;&lt;/code&gt;, which isn't even a valid slug under the constraint from Step 4). If you need per-tenant sub-paths like that, treat it as a separate, deliberate feature — don't assume it falls out of wildcard support for free.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Update your production environment variable to match:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;NEXT_PUBLIC_ROOT_DOMAIN=yourapp.com
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If tenants will ever be able to publish their own content or run their own code on your subdomains (not just view data you control), submit your domain to the &lt;a rel=&quot;nofollow&quot; href=&quot;https://publicsuffix.org/&quot;&gt;Public Suffix List&lt;/a&gt; so browsers isolate cookies between &lt;code&gt;tenant1.yourapp.com&lt;/code&gt; and &lt;code&gt;tenant2.yourapp.com&lt;/code&gt;. This matters more for user-generated-content platforms than a typical internal-dashboard SaaS.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Important:&lt;/strong&gt; If you become a platform that gives mutually untrusted customers control over content or code on separate subdomains, investigate whether your domain qualifies for the PSL PRIVATE section. This changes browser cookie boundaries, so understand the consequences before applying.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;what-about-custom-domains-&quot;&gt;What About Custom Domains?&lt;/h4&gt;
&lt;p&gt;Letting a tenant use their own domain (&lt;code&gt;app.acme.com&lt;/code&gt; instead of &lt;code&gt;acme.yourapp.com&lt;/code&gt;) is a common upsell for higher-tier plans, but it's meaningfully more setup: you provision the domain onto your Vercel project programmatically (via the Vercel SDK), verify the tenant actually owns it with a TXT record, and let Vercel auto-issue SSL once verified. The tenant-resolution logic in &lt;code&gt;proxy.ts&lt;/code&gt; from &lt;a rel=&quot;nofollow&quot; href=&quot;#resolve-the-tenant-in-proxy&quot;&gt;Step 2&lt;/a&gt; extends naturally — you'd just add a lookup by full hostname (&lt;code&gt;domain_${hostname}&lt;/code&gt;) before falling back to subdomain parsing. Treat this as a Phase 2 feature; ship subdomains first.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;production-checklist&quot;&gt;Production Checklist&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;proxy.ts&lt;/code&gt; deletes inbound &lt;code&gt;x-tenant-*&lt;/code&gt; headers before setting its own — never trust client-supplied tenant headers&lt;/li&gt;
&lt;li&gt;[ ] An unresolved tenant subdomain redirects to the &lt;strong&gt;root domain&lt;/strong&gt;, not the tenant's own (bad) host — otherwise it loops&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;proxy.ts&lt;/code&gt; refreshes the Supabase auth session on every request, not just tenant headers&lt;/li&gt;
&lt;li&gt;[ ] Every tenant-owned table has a &lt;code&gt;tenant_id&lt;/code&gt; column, a matching index, and RLS enabled&lt;/li&gt;
&lt;li&gt;[ ] RLS policies exist for &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;insert&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, and &lt;code&gt;delete&lt;/code&gt; — not just &lt;code&gt;select&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] No policy anywhere uses a bare &lt;code&gt;using (true)&lt;/code&gt; unless a resource is genuinely meant to be public&lt;/li&gt;
&lt;li&gt;[ ] Server Actions derive &lt;code&gt;tenant_id&lt;/code&gt; from the resolved server-side tenant context, never from client-submitted form data&lt;/li&gt;
&lt;li&gt;[ ] Reserved subdomains (&lt;code&gt;www&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;api&lt;/code&gt;) are excluded from tenant resolution&lt;/li&gt;
&lt;li&gt;[ ] Tenant slugs are constrained as valid single-label DNS names (no leading/trailing hyphens, ≤63 characters)&lt;/li&gt;
&lt;li&gt;[ ] Wildcard domain (&lt;code&gt;*.yourapp.com&lt;/code&gt;) and apex domain are both added in Vercel, with nameservers delegated&lt;/li&gt;
&lt;li&gt;[ ] anon/authenticated table grants are explicitly restricted to only the operations the app requires&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Multi-tenant architecture in Next.js 16 comes down to two layers working together: &lt;code&gt;proxy.ts&lt;/code&gt; figures out &lt;em&gt;which&lt;/em&gt; tenant a request belongs to and hands that context downstream as a header, while Postgres Row Level Security makes sure that context is actually enforced — not just trusted — every time data gets read or written. Get those two pieces right, and &quot;which company does this row belong to?&quot; stops being a question your application code has to get right on every single query; it becomes a guarantee your database makes for you.&lt;/p&gt;
&lt;p&gt;From here, natural next steps are layering &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;role-based permissions&lt;/a&gt; on top of tenant membership (owner vs. admin vs. member), building a tenant-scoped billing flow with &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/stripe-subscription-billing-nextjs-16&quot;&gt;Stripe subscriptions&lt;/a&gt;, and revisiting &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/session-vs-jwt-for-nextjs-16&quot;&gt;session vs. JWT&lt;/a&gt; if you want tenant context embedded directly in the session instead of resolved fresh on every request.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-supabase-todo-app&quot;&gt;Build a Todo App with Next.js 16 and Supabase (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/session-vs-jwt-for-nextjs-16&quot;&gt;Session vs JWT: Which Should You Use in Next.js 16?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-route-handlers&quot;&gt;Route Handlers Explained in Next.js 16 (Beginner's Guide)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;free-developer-tools&quot;&gt;Free Developer Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; — generate strict, tenant/team-scoped Row Level Security policies like the ones in this guide, including the &lt;code&gt;auth.uid()&lt;/code&gt; performance wrapper and a security-definer helper function, without hand-writing SQL.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27782/multi-tenant-architecture-in-next-js-16-2026</guid>
<pubDate>Sun, 20 Sep 2026 03:02:16 +0000</pubDate>
</item>
<item>
<title>Project LifeOps (Part 1): Personal Operating System Architecture and FastAPI + Supabase Backend</title>
<link>https://coderlegion.com/26753/project-lifeops-part-personal-operating-system-architecture-fastapi-supabase-backend</link>
<description>&lt;p&gt;How many apps do you use daily to manage your life? If you are anything like me, you probably have one app to track your runs and gym workouts, another to keep tabs on books, a website to rate movies and TV series, and two or three more tools for project management, tasks, and notes.&lt;/p&gt;
&lt;p&gt;The result is almost always the same: &lt;strong&gt;scattered data silos, mounting monthly subscription bills, and zero ability to cross-analyze data or generate a consolidated monthly executive review&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;With that problem in mind and true to the Datalaria ethos of &quot;learning by building&quot;, I decided to design and engineer &lt;strong&gt;LifeOps&lt;/strong&gt;: an &lt;strong&gt;All-in-One Personal &amp;amp; Professional Operating System (OS)&lt;/strong&gt; that unifies under a single responsive dashboard:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;🏃 &lt;strong&gt;Fitness &amp;amp; Performance&lt;/strong&gt;: Track workouts (Running, Cycling, Gym), distance volume, Personal Bests (PB), and calories.&lt;/li&gt;
&lt;li&gt;📚 &lt;strong&gt;Reading &amp;amp; Culture&lt;/strong&gt;: Book progress tracking, percentage bars, and ratings.&lt;/li&gt;
&lt;li&gt;🎬 &lt;strong&gt;Cinema &amp;amp; Entertainment&lt;/strong&gt;: Movie, TV series, and documentary catalog organized by platform.&lt;/li&gt;
&lt;li&gt;📋 &lt;strong&gt;Professional Portfolio &amp;amp; Tasks&lt;/strong&gt;: Interactive Kanban board, budget tracking, and deliverable milestones.&lt;/li&gt;
&lt;li&gt;📊 &lt;strong&gt;Reporting &amp;amp; Data Portability&lt;/strong&gt;: Streaming Word (&lt;code&gt;.docx&lt;/code&gt;) executive dossiers, multi-sheet Excel workbooks (&lt;code&gt;.xlsx&lt;/code&gt;), and raw CSV exports with UTF-8 BOM.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Best of all: &lt;strong&gt;built on top of a rock-solid, production-grade architecture that is 100% Free ($0/month)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In this first installment of the series, we dive into the core foundations: &lt;strong&gt;cloud architecture design&lt;/strong&gt;, &lt;strong&gt;PostgreSQL relational data modeling&lt;/strong&gt;, and building the &lt;strong&gt;high-performance FastAPI backend&lt;/strong&gt;. Let's get into it! 🚀&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;[!TIP]&lt;br&gt;
&lt;strong&gt;Test the live app&lt;/strong&gt;: You can explore the production build of LifeOps directly at &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/apps/lifeops/&quot;&gt;https://datalaria.com/apps/lifeops/&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 id=&quot;-lifeops-series-roadmap&quot;&gt;🗺️ LifeOps Series Roadmap&lt;/h4&gt;
&lt;p&gt;To understand how every architectural layer fits together, this series spans 5 structured installments:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;🟢 &lt;strong&gt;Part 1 (This article)&lt;/strong&gt;: Personal Operating System Architecture and FastAPI + Supabase Backend.&lt;/li&gt;
&lt;li&gt;⚪ &lt;strong&gt;Part 2&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/app-lifeops_part2_frontend_dashboard/&quot;&gt;React Frontend with Glassmorphism, 360° Dashboard, and Design System&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;⚪ &lt;strong&gt;Part 3&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/app-lifeops_part3_modulos_kanban/&quot;&gt;Core Interactive Modules: Fitness, Library, Cinema, and Professional Kanban Board&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;⚪ &lt;strong&gt;Part 4&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/app-lifeops_part4_informes_word_excel/&quot;&gt;In-Memory Word Dossiers (.docx) &amp;amp; Multi-Sheet Excel Engine (.xlsx)&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;⚪ &lt;strong&gt;Part 5&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/app-lifeops_part5_deploy_mobile_pwa/&quot;&gt;24/7 Zero-Cost Cloud Deployment ($0/month), Mobile UX, and PWA&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-0-month-architecture-how-to-run-free-at-scale-&quot;&gt;The $0/Month Architecture: How to Run Free at Scale 💡&lt;/h4&gt;
&lt;p&gt;When architecting LifeOps, the goal wasn't merely to run on &lt;code&gt;localhost&lt;/code&gt;, but to deploy a production-grade system with zero recurring hosting costs using the most generous free tiers available:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────┐
│                       CLIENT BROWSER                        │
│            [https://datalaria.com/apps/lifeops](https://datalaria.com/apps/lifeops)               │
└──────────────────────────────┬──────────────────────────────┘
                               │ (Proxy Rewrite 200)
                               ▼
┌─────────────────────────────────────────────────────────────┐
│               FRONTEND: Netlify CDN ($0/month)              │
│          React 18 + Vite 8 + i18n (ES/EN) + CSS Glass       │
└──────────────────────────────┬──────────────────────────────┘
                               │
                JWT Auth Token │  REST API Calls / Streaming
                               ▼
┌─────────────────────────────────────────────────────────────┐
│               BACKEND: FastAPI + Python 3.13                │
│    • In-Memory Docx Generator (python-docx + io.BytesIO)    │
│    • Multi-Sheet Excel Engine (openpyxl)                    │
│    • Anti-DoS Rate Limiting (slowapi) + Strict CORS         │
└──────────────────────────────┬──────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────┐
│              DATABASE: Supabase Managed Cloud               │
│       • PostgreSQL with isolated `lifeops` schema           │
│       • Row Level Security (RLS) &amp;amp; Supabase Auth            │
└─────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Frontend on Netlify ($0/mo)&lt;/strong&gt;: Single Page Application built with Vite and served globally with Gzip compression, route-level code splitting, and transparent proxy rewrite under Datalaria's domain.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backend on FastAPI ($0/mo)&lt;/strong&gt;: Asynchronous Python 3.13 API with strict validation via Pydantic v2, hosted on Render with cold-start resilience.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database on Supabase ($0/mo)&lt;/strong&gt;: Managed PostgreSQL database with 500 MB of storage, JWT authentication, and automated Row Level Security (RLS) policies.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;[!NOTE]&lt;br&gt;
&lt;strong&gt;Render Cold-Start Handling&lt;/strong&gt;: Because the backend runs on Render.com's free tier, the container spins down after 15 minutes of inactivity. In the React frontend (detailed in Part 2), we implemented a preloader screen with automatic exponential-backoff retries, ensuring the user experiences zero abrupt connection drops.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 id=&quot;relational-data-modeling-clean-polymorphism-in-postgresql-&quot;&gt;Relational Data Modeling: Clean Polymorphism in PostgreSQL 🗄️&lt;/h4&gt;
&lt;p&gt;One of the biggest design challenges when combining disparate activities (a 10 km run, a 400-page book, or a Netflix movie) is avoiding monolithic tables cluttered with &lt;code&gt;NULL&lt;/code&gt; columns.&lt;/p&gt;
&lt;p&gt;To solve this, I designed a &lt;strong&gt;master table with specialized child extensions (1-to-1)&lt;/strong&gt; inside an isolated &lt;code&gt;lifeops&lt;/code&gt; schema:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 1. Master Activities Table
CREATE TABLE lifeops.activities (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    activity_type VARCHAR(30) NOT NULL, -- 'sport', 'book', 'film'
    title VARCHAR(200) NOT NULL,
    date DATE NOT NULL DEFAULT CURRENT_DATE,
    duration_minutes INTEGER,
    rating INTEGER CHECK (rating &amp;gt;= 1 AND rating &amp;lt;= 5),
    description TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 2. Fitness Extension (Workouts)
CREATE TABLE lifeops.workouts (
    activity_id UUID PRIMARY KEY REFERENCES lifeops.activities(id) ON DELETE CASCADE,
    workout_type VARCHAR(50) NOT NULL, -- 'running', 'cycling', 'gym', etc.
    distance_km NUMERIC(6,2),
    calories INTEGER,
    avg_heart_rate INTEGER,
    elevation_m INTEGER,
    personal_best BOOLEAN DEFAULT FALSE,
    notes TEXT
);

-- 3. Reading Extension (Books)
CREATE TABLE lifeops.books (
    activity_id UUID PRIMARY KEY REFERENCES lifeops.activities(id) ON DELETE CASCADE,
    author VARCHAR(150),
    pages_total INTEGER,
    pages_read INTEGER DEFAULT 0,
    status VARCHAR(30) DEFAULT 'reading', -- 'reading', 'completed', 'wishlist'
    genre VARCHAR(50)
);

-- 4. Cinema Extension (Films)
CREATE TABLE lifeops.films (
    activity_id UUID PRIMARY KEY REFERENCES lifeops.activities(id) ON DELETE CASCADE,
    media_type VARCHAR(30) DEFAULT 'movie', -- 'movie', 'series', 'documentary'
    director VARCHAR(150),
    platform VARCHAR(50), -- 'Cine', 'Netflix', 'HBO Max', 'Prime Video'
    genre VARCHAR(50),
    year INTEGER
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;why-this-architecture-shines-&quot;&gt;Why this architecture shines:&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Blazing Fast Global Aggregations&lt;/strong&gt;: Dashboard queries hit only &lt;code&gt;lifeops.activities&lt;/code&gt; for KPI counts and calendar activity heatmaps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cascading Referencial Integrity&lt;/strong&gt;: When an activity is removed, PostgreSQL's &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; automatically purges child tables without orphaned rows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row-Level Security (RLS)&lt;/strong&gt;: Each user can only read and mutate their own data via declarative security policies:&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE POLICY &quot;Users can only view their own activities&quot;
ON lifeops.activities FOR SELECT
USING (auth.uid() = user_id);
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;[!IMPORTANT]&lt;br&gt;
&lt;strong&gt;Isolated Schemas &amp;amp; Permissions in Supabase&lt;/strong&gt;: When maintaining tables in a dedicated schema like &lt;code&gt;lifeops&lt;/code&gt; (rather than default &lt;code&gt;public&lt;/code&gt;), ensure you grant explicit schema access (&lt;code&gt;GRANT USAGE ON SCHEMA lifeops TO authenticated;&lt;/code&gt;) and table permissions to the &lt;code&gt;authenticated&lt;/code&gt; role. This isolates the operating system's database domain while honoring multi-tenant security.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-fastapi-backend-modular-architecture-amp-strict-typing-&quot;&gt;The FastAPI Backend: Modular Architecture &amp;amp; Strict Typing ⚡&lt;/h4&gt;
&lt;p&gt;To organize the API cleanly as business domains expand, we follow FastAPI's &lt;strong&gt;Modular Router pattern&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;lifeops-api/
├── main.py                  # Entrypoint, Middlewares, Rate Limiter &amp;amp; CORS
├── config.py                # Centralized environment settings with Pydantic Settings
├── middleware/
│   └── auth.py              # Supabase JWT validation &amp;amp; secure user_id extraction
├── models/
│   ├── activity.py          # Pydantic Schemas (Create, Update, Response)
│   └── project.py           # Schemas for Tasks and Projects
├── routers/
│   ├── activities.py        # CRUD endpoints for Fitness, Books &amp;amp; Cinema
│   ├── projects.py          # Endpoints for Kanban Board and Projects
│   ├── stats.py             # Global KPI aggregations &amp;amp; Dashboard metrics
│   ├── reports.py           # Word (.docx) generator &amp;amp; CSV/Excel streaming
│   └── alerts.py            # Intelligent reminder engine
└── services/
    ├── report_generator.py  # In-memory RAM .docx compilation
    └── data_exporter.py     # CSV (BOM) &amp;amp; multi-sheet Excel generator
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;secure-supabase-jwt-authentication&quot;&gt;Secure Supabase JWT Authentication&lt;/h5&gt;
&lt;p&gt;Every endpoint processing user data injects the &lt;code&gt;get_current_user&lt;/code&gt; dependency:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt

security = HTTPBearer()

async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -&amp;gt; AuthenticatedUser:
    token = credentials.credentials
    try:
        payload = jwt.decode(
            token,
            settings.supabase_jwt_secret,
            algorithms=[&quot;HS256&quot;],
            audience=&quot;authenticated&quot;
        )
        user_id = payload.get(&quot;sub&quot;)
        if not user_id:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=&quot;Token missing user identity&quot;)
        return AuthenticatedUser(id=user_id, email=payload.get(&quot;email&quot;))
    except jwt.PyJWTError as e:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=&quot;Invalid or expired token&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;production-hardening-rate-limiting-amp-anti-abuse-&quot;&gt;Production Hardening: Rate Limiting &amp;amp; Anti-Abuse 🛡️&lt;/h4&gt;
&lt;p&gt;Because LifeOps supports on-the-fly Word report compilation and multi-sheet Excel generation, it was crucial to prevent resource starvation or DoS attacks.&lt;/p&gt;
&lt;p&gt;We integrated &lt;strong&gt;Rate Limiting with &lt;code&gt;slowapi&lt;/code&gt;&lt;/strong&gt; directly into the FastAPI application:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

limiter = Limiter(key_func=get_remote_address, default_limits=[&quot;120/minute&quot;])
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

# Strict rate limit for CPU/memory-intensive Word generation
@router.post(&quot;/generate&quot;)
@limiter.limit(&quot;10/minute&quot;)
def generate_report(request: Request, req: GenerateReportRequest, user = Depends(get_current_user)):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a client exceeds the threshold, the API responds instantly with an &lt;strong&gt;HTTP 429 Too Many Requests&lt;/strong&gt;, protecting compute resources.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;conclusion-and-what-s-next-&quot;&gt;Conclusion and What's Next 🎯&lt;/h4&gt;
&lt;p&gt;With our FastAPI backend hardened, the PostgreSQL relational schema operational on Supabase, and rate limiting active, we have a production-grade foundation running at &lt;strong&gt;$0/month&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In &lt;strong&gt;Part 2&lt;/strong&gt; of this series, we dive straight into the frontend:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Single Page App development with &lt;strong&gt;React 18/19, Vite, and Glassmorphism design&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Internationalization (i18n)&lt;/strong&gt; system for instant Spanish 🇪🇸 / English 🇬🇧 locale toggling.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;360° Dashboard&lt;/strong&gt; architecture with aggregated KPI widgets and interactive &lt;strong&gt;Recharts&lt;/strong&gt; visualizations.&lt;/li&gt;
&lt;li&gt;Authentication context (&lt;code&gt;AuthContext&lt;/code&gt;) and session synchronization with Supabase.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;references-amp-useful-links-&quot;&gt;References &amp;amp; Useful Links 🔗&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;🚀 &lt;strong&gt;Production Application&lt;/strong&gt;: Try the live app at &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/apps/lifeops/&quot;&gt;datalaria.com/apps/lifeops&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;🌐 &lt;strong&gt;Production REST API&lt;/strong&gt;: Interactive OpenAPI Swagger documentation at &lt;a rel=&quot;nofollow&quot; href=&quot;https://lifeops-api.onrender.com/docs&quot;&gt;lifeops-api.onrender.com/docs&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;⚡ &lt;strong&gt;FastAPI Framework&lt;/strong&gt;: Official documentation at &lt;a rel=&quot;nofollow&quot; href=&quot;https://fastapi.tiangolo.com/&quot;&gt;fastapi.tiangolo.com&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;🗄️ &lt;strong&gt;Supabase Cloud&lt;/strong&gt;: Guide to PostgreSQL and Row Level Security at &lt;a rel=&quot;nofollow&quot; href=&quot;https://supabase.com/docs&quot;&gt;supabase.com/docs&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;🛡️ &lt;strong&gt;SlowAPI&lt;/strong&gt;: Rate limiting for ASGI/FastAPI at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/laurentS/slowapi&quot;&gt;github.com/laurentS/slowapi&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;See you in the next installment! Feel free to leave a comment below or connect on socials. 👇&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/26753/project-lifeops-part-personal-operating-system-architecture-fastapi-supabase-backend</guid>
<pubDate>Thu, 10 Sep 2026 17:06:49 +0000</pubDate>
</item>
<item>
<title>I Moved My Next.js Dashboard Logic Into Postgres. My Frontend Got Boring (And That's the Point).</title>
<link>https://coderlegion.com/25362/i-moved-my-next-js-dashboard-logic-into-postgres-my-frontend-got-boring-and-thats-the-point</link>
<description>&lt;p&gt;My dashboard had a &lt;code&gt;useMemo&lt;/code&gt; doing arithmetic it had no business doing.&lt;/p&gt;
&lt;p&gt;It was a Pokémon TCG Pocket collection tracker, but that part doesn't matter. What matters is that the home page needed to show three things: overall completion, completion per set, and which set you were closest to finishing. The way I'd built it, the browser was fetching every card and every owned record, then grinding through the math on each render to figure all of that out.&lt;/p&gt;
&lt;p&gt;It worked. It also got slower and harder to read every time the data grew or I added a metric. So I moved the aggregation out of React and into Postgres, and the surprising result was that my frontend got &lt;em&gt;boring&lt;/em&gt;. Fewer hooks, less state, almost nothing left to break.&lt;/p&gt;
&lt;p&gt;That's the whole argument of this post: aggregation belongs in the database, and when you put it there, the React code that's left over is the kind of boring you actually want in the layer your users touch.&lt;/p&gt;
&lt;h3 id=&quot;what-fetching-everything-into-react-actually-looks-like&quot;&gt;What &quot;fetching everything into React&quot; actually looks like&lt;/h3&gt;
&lt;p&gt;Here's the shape of the original dashboard. Load all the cards, load the user's owned rows, then derive everything on the client.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const [cards, setCards] = useState&amp;lt;Card[]&amp;gt;([]);
const [owned, setOwned] = useState&amp;lt;OwnedCard[]&amp;gt;([]);

useEffect(() =&amp;gt; {
  (async () =&amp;gt; {
    const { data: allCards } = await supabase.from(&quot;cards&quot;).select(&quot;*&quot;);
    const { data: ownedRows } = await supabase
      .from(&quot;user_cards&quot;)
      .select(&quot;card_id&quot;);
    setCards(allCards ?? []);
    setOwned(ownedRows ?? []);
  })();
}, []);

const ownedIds = useMemo(() =&amp;gt; new Set(owned.map((o) =&amp;gt; o.card_id)), [owned]);

const perSet = useMemo(() =&amp;gt; {
  const groups: Record&amp;lt;string, { total: number; have: number }&amp;gt; = {};
  for (const card of cards) {
    const g = (groups[card.set_id] ??= { total: 0, have: 0 });
    g.total += 1;
    if (ownedIds.has(card.id)) g.have += 1;
  }
  return groups;
}, [cards, ownedIds]);

const overall = useMemo(() =&amp;gt; {
  const total = cards.length;
  const have = cards.filter((c) =&amp;gt; ownedIds.has(c.id)).length;
  return total ? have / total : 0;
}, [cards, ownedIds]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's not &lt;em&gt;bad&lt;/em&gt; code. But look at what it's costing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The payload grows with the collection.&lt;/strong&gt; I'm shipping every card row to the browser just to count them. The user never sees most of that data. It exists only to be reduced to a few numbers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The logic can't be tested without mounting a component.&lt;/strong&gt; The &quot;closest set to finishing&quot; rule lives inside hooks, tangled up with fetching and state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every new metric is more client state.&lt;/strong&gt; Want a &quot;rarest card you're missing&quot; stat? That's another loop, another &lt;code&gt;useMemo&lt;/code&gt;, another thing that re-runs on render.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I was using React as a database query engine. It's not one.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-the-wrong-layer&quot;&gt;Why this is the wrong layer&lt;/h3&gt;
&lt;p&gt;Counting, grouping, and computing ratios across rows is a set operation. It's the thing relational databases were built to do, indexed and compiled, sitting right next to the data.&lt;/p&gt;
&lt;p&gt;When I do it in React instead, I pay for the same work twice. Once to serialize every row and send it over the wire, and again to recompute the aggregates in JavaScript on the client. The database already knew the answer was &quot;you own 41 of 86 cards in this set.&quot; I just declined to ask it that question and asked for all 86 rows instead.&lt;/p&gt;
&lt;p&gt;This isn't really a performance micro-optimization. It's a question of which layer owns which job. Aggregation is a database job. I'd put it in the wrong place.&lt;/p&gt;
&lt;h3 id=&quot;moving-it-into-an-rpc-function&quot;&gt;Moving it into an RPC function&lt;/h3&gt;
&lt;p&gt;Supabase lets you call Postgres functions directly with &lt;code&gt;rpc()&lt;/code&gt;, so I wrote one that returns exactly the rows the dashboard needs: one row per set, with the counts already computed.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create or replace function public.get_home_set_completion_rows()
returns table (
  set_id text,
  set_name text,
  set_slug text,
  release_date date,
  image_url text,
  data_version text,
  total_cards integer,
  owned_cards integer,
  missing_cards integer
)
language sql
stable
security invoker
set search_path = public
as \(
  select
    sets.id as set_id,
    sets.name as set_name,
    sets.slug as set_slug,
    sets.release_date,
    sets.image_url,
    sets.data_version,
    count(cards.id)::integer as total_cards,
    count(user_cards.card_id)::integer as owned_cards,
    (count(cards.id) - count(user_cards.card_id))::integer as missing_cards
  from public.sets
  left join public.cards
    on cards.set_id = sets.id
  left join public.user_cards
    on user_cards.card_id = cards.id
    and user_cards.user_id = auth.uid()
  group by
    sets.id,
    sets.name,
    sets.slug,
    sets.release_date,
    sets.image_url,
    sets.data_version
  order by
    sets.release_date desc nulls last,
    sets.id;
\);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;left join&lt;/code&gt; against &lt;code&gt;user_cards&lt;/code&gt; filtered by &lt;code&gt;auth.uid()&lt;/code&gt; is doing the per-user work right inside the query. Sets the user has zero cards from still show up with &lt;code&gt;owned = 0&lt;/code&gt;, which is what I want.&lt;/p&gt;
&lt;p&gt;And the call site collapses to this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { data } = await supabase.rpc(&quot;get_home_set_completion_rows&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it. No fetching everything, no client-side grouping, no &lt;code&gt;Set&lt;/code&gt; of owned IDs. The component receives compact, purpose-built rows and maps over them. Overall completion is a sum of two columns. &quot;Closest to finishing&quot; is a sort. Both are now trivial because the hard part already happened in the database.&lt;/p&gt;
&lt;p&gt;The React file went from a pile of hooks to a Server Component that calls one function and renders the result. That's the &quot;my frontend got boring&quot; moment, and deleting all that state felt great.&lt;/p&gt;
&lt;h3 id=&quot;when-a-view-beats-a-function-and-vice-versa-&quot;&gt;When a view beats a function (and vice versa)&lt;/h3&gt;
&lt;p&gt;I reached for an RPC function here because of that &lt;code&gt;auth.uid()&lt;/code&gt; filter, since the result depends on who's asking. But it's worth knowing when a plain view is the better tool:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Use a view&lt;/strong&gt; when the aggregation is stable, reusable, and doesn't need parameters. A view reads like a table, composes into other queries, and is the simplest thing that works. Card counts per set that &lt;em&gt;aren't&lt;/em&gt; user-specific would be a great view.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use a function&lt;/strong&gt; when you need arguments (a user ID, a set filter, a scope toggle) or multi-step logic that a single &lt;code&gt;select&lt;/code&gt; can't express cleanly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One footgun worth flagging: both views and functions interact with row-level security, and &lt;code&gt;security definer&lt;/code&gt; versus &lt;code&gt;security invoker&lt;/code&gt; decides whose permissions the code runs under. A &lt;code&gt;security definer&lt;/code&gt; function bypasses the caller's RLS, which is occasionally what you want and frequently a way to leak other users' data by accident. If you're using RLS to protect per-user rows, and you should be, make sure your function respects it rather than quietly stepping around it.&lt;/p&gt;
&lt;h3 id=&quot;the-objections-worth-taking-seriously-&quot;&gt;The objections worth taking seriously.&lt;/h3&gt;
&lt;p&gt;A few reasonable complaints, because this trade isn't free.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;Now my logic is split across two languages and harder to grep.&quot;&lt;/strong&gt; True. I moved complexity, I didn't delete it. The aggregation now lives in SQL instead of TypeScript, so there's a context switch to read the whole story. My argument is just that it lands in the &lt;em&gt;right&lt;/em&gt; place, with the database owning database work, and that the SQL version is shorter and more declarative than the loops it replaced.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;SQL is harder to test.&quot;&lt;/strong&gt; I'd push back on this one. The function is deterministic: give it fixture rows and it produces the same counts every time, with no component to mount. And separating it out made the logic that &lt;em&gt;correctly&lt;/em&gt; stayed in TypeScript easier to test too. My pack-recommendation engine, which sums pull probabilities for missing cards and ranks packs by expected value, is real application logic, not aggregation, so it belongs in app code. Once it wasn't tangled up with fetching and dashboard math, I could unit-test it on its own. The point isn't &quot;move everything into Postgres.&quot; It's &quot;move the aggregation, keep the decisions.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;This is just more infrastructure to manage.&quot;&lt;/strong&gt; Fair, but it's version-controlled SQL living next to the rest of the schema, not a magic black box. It migrates like everything else.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-of-thumb&quot;&gt;The rule of thumb&lt;/h3&gt;
&lt;p&gt;If you take one thing from this post, take this:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Push aggregation and filtering down to the database. Keep decisions and presentation in the app.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Or, more bluntly: if React is looping to count things, that loop probably wants to be a &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The frontend got boring, with smaller payloads, fewer hooks, and less state to corrupt, and boring is exactly what you want from the layer your users actually touch. The interesting, opinionated, easy-to-get-wrong logic moved to where it could be expressed simply and tested in isolation.&lt;/p&gt;
&lt;p&gt;The genuinely contested part is &lt;em&gt;where you draw the line&lt;/em&gt;. I keep ranking and business decisions in app code and push counting and filtering into the database, but I know people who'd move more in either direction. Where do you draw it?&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Full code, if you want to see the whole thing wired together: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mwiginton/pocketdex-tracker&quot;&gt;mwiginton/pocketdex-tracker&lt;/a&gt;. Or try the live app: &lt;a rel=&quot;nofollow&quot; href=&quot;https://pocketdex-tracker.vercel.app/auth/login&quot;&gt;pocketdex-tracker.vercel.app&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25362/i-moved-my-next-js-dashboard-logic-into-postgres-my-frontend-got-boring-and-thats-the-point</guid>
<pubDate>Tue, 25 Aug 2026 16:16:35 +0000</pubDate>
</item>
<item>
<title>How I Built Multi-User Apps with Row-Level Security in Supabase</title>
<link>https://coderlegion.com/25297/how-i-built-multi-user-apps-with-row-level-security-in-supabase</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.clintech.me/blog/building-multi-user-apps-with-rls&quot;&gt;clintech.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When you move from toy projects to real products, the hard problem &lt;br&gt;
isn't styling cards. It's isolation: how do you make sure each user &lt;br&gt;
only sees their own data?&lt;/p&gt;
&lt;p&gt;The wrong answer: filter by &lt;code&gt;userId&lt;/code&gt; in React and hope no one cheats.&lt;br&gt;
The right answer: Row-Level Security.&lt;/p&gt;
&lt;h3 id=&quot;what-rls-actually-is&quot;&gt;What RLS actually is&lt;/h3&gt;
&lt;p&gt;RLS is a Postgres feature that enforces access policies at the database &lt;br&gt;
level — not the application level. Every query runs through your &lt;br&gt;
policies before returning data.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE POLICY &quot;Users can see their own jobs&quot;
ON jobs FOR SELECT
USING (user_id = auth.uid());
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this in place, even if someone opens DevTools and calls the &lt;br&gt;
Supabase REST API directly with a valid token, they still only get rows &lt;br&gt;
where &lt;code&gt;user_id&lt;/code&gt; matches their own auth UID. The database is the &lt;br&gt;
security gate, not your frontend.&lt;/p&gt;
&lt;h3 id=&quot;how-i-applied-this-in-applycraft&quot;&gt;How I applied this in ApplyCraft&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apply-craft.vercel.app&quot;&gt;ApplyCraft&lt;/a&gt; is an AI-powered job &lt;br&gt;
tracker I built. Every job, note, and profile is scoped per user. &lt;br&gt;
The policy pattern is consistent across all tables:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;jobs.user_id = auth.uid()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;job_notes.user_id = auth.uid()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;profiles.id = auth.uid()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even the server-side AI endpoint, which writes outreach copy back &lt;br&gt;
to a job row, verifies ownership before touching the database. &lt;br&gt;
No client-side key exposure, no trust in the frontend.&lt;/p&gt;
&lt;h3 id=&quot;the-mistakes-that-will-burn-you&quot;&gt;The mistakes that will burn you&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Enabling RLS but leaving &quot;allow all&quot; policies&lt;/strong&gt;&lt;br&gt;
You've technically turned it on but effectively disabled it. &lt;br&gt;
Every policy needs to be explicit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Forgetting write policies&lt;/strong&gt;&lt;br&gt;
Easy to add &lt;code&gt;SELECT&lt;/code&gt; and forget &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;. &lt;br&gt;
Your app will silently fail on writes and you'll spend an hour &lt;br&gt;
wondering why.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Trusting only frontend filters&lt;/strong&gt;&lt;br&gt;
If a table has unrestricted &lt;code&gt;SELECT&lt;/code&gt;, any user can bypass your &lt;br&gt;
UI and call the REST endpoint directly. RLS removes that &lt;br&gt;
possibility entirely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Not following the ownership chain on joins&lt;/strong&gt;&lt;br&gt;
If &lt;code&gt;notes&lt;/code&gt; belongs to &lt;code&gt;jobs&lt;/code&gt; and &lt;code&gt;jobs&lt;/code&gt; belongs to a user, your &lt;br&gt;
note policies should check ownership through that chain, not &lt;br&gt;
just assume the foreign key is enough.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The difference between &quot;multi-user UI&quot; and &quot;multi-tenant product&quot; &lt;br&gt;
is where security lives. Once the database enforces it, your &lt;br&gt;
frontend gets simpler and your app gets much harder to abuse.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I'm a frontend engineer open to remote EU roles, connect with &lt;br&gt;
me on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/clinton-atayero-3800b5238/&quot;&gt;LinkedIn&lt;/a&gt; &lt;br&gt;
if this was useful.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25297/how-i-built-multi-user-apps-with-row-level-security-in-supabase</guid>
<pubDate>Mon, 24 Aug 2026 13:51:29 +0000</pubDate>
</item>
<item>
<title>Feature Flags in SaaS: A Beginner's Guide with Next.js 16 (2026)</title>
<link>https://coderlegion.com/25162/feature-flags-in-saas-a-beginners-guide-with-next-js-16-2026</link>
<description>&lt;p&gt;You just shipped a new billing page. It works perfectly in your local environment, QA gives it a thumbs up, and you deploy on a Friday afternoon (bold choice). Twenty minutes later, support tickets start rolling in — the new checkout flow is broken for a chunk of your users, and the only fix is a full redeploy, which means more downtime while you scramble.&lt;/p&gt;
&lt;p&gt;Here's the thing: that entire scenario is avoidable. Not by testing harder, but by changing &lt;em&gt;how&lt;/em&gt; you ship. If that billing page had been behind a &lt;strong&gt;feature flag&lt;/strong&gt;, you could've turned it off instantly — no redeploy, no rollback, no 2am panic. Just a toggle switch.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll understand exactly what feature flags are, why almost every real SaaS product uses them, and you'll have a working feature flag system built into a Next.js 16 app — from a simple database-backed version you can build yourself, to Vercel's official Flags SDK for a more polished setup.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-is-tricky-1&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;Feature flags sound simple — &quot;just an if statement,&quot; right? And technically, that's true. But most beginners either overuse them (turning every branch of code into a flag, creating a tangled mess) or underuse them (skipping flags entirely and relying only on &lt;code&gt;git push&lt;/code&gt; to control what users see).&lt;/p&gt;
&lt;p&gt;The real skill isn't writing an &lt;code&gt;if&lt;/code&gt; statement — it's answering three questions correctly:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Where should the flag be checked?&lt;/strong&gt; Client, server, or both?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Where should the flag's value live?&lt;/strong&gt; Hardcoded, a database, or a third-party service?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What happens when the flag is wrong or missing?&lt;/strong&gt; Does your app break, or fail safely?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We'll answer all three as we build this out.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-a-feature-flag-exactly-&quot;&gt;What Is a Feature Flag, Exactly?&lt;/h3&gt;
&lt;p&gt;A feature flag (also called a feature toggle) is a switch in your code that controls whether a piece of functionality is active — without needing to change or redeploy your code to flip it.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Think of it like:&lt;/strong&gt; a light switch wired into your wall during construction, instead of soldering a lamp directly to the power line. The wiring (your code) ships once. Whether the light is on or off is a decision you can change any time, without touching the wall again.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In practice, a feature flag is just a boolean (or sometimes a string/number) that your code checks before rendering something or running some logic:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;if (isNewCheckoutEnabled) {
  return &amp;lt;NewCheckout /&amp;gt;;
}

return &amp;lt;OldCheckout /&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The interesting part isn't that &lt;code&gt;if&lt;/code&gt; statement — it's &lt;em&gt;where &lt;code&gt;isNewCheckoutEnabled&lt;/code&gt; comes from&lt;/em&gt; and &lt;em&gt;how you control it&lt;/em&gt;. That's what the rest of this guide covers.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-saas-products-rely-on-feature-flags&quot;&gt;Why SaaS Products Rely on Feature Flags&lt;/h3&gt;
&lt;p&gt;A handful of concrete reasons feature flags matter, especially for SaaS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ship without releasing.&lt;/strong&gt; You can merge and deploy unfinished code to production behind a flag that's off. This is called &quot;trunk-based development&quot; — your main branch always reflects what's deployed, but not everything deployed is visible yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Instant kill switch.&lt;/strong&gt; If something breaks, flip the flag off. No git revert, no redeploy, no waiting for a build to finish while your app is broken.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gradual rollouts.&lt;/strong&gt; Release a risky feature to 5% of users first, watch for errors, then dial it up to 25%, 50%, 100% — instead of an all-or-nothing launch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Paid plan gating.&lt;/strong&gt; Show a feature only to customers on your Pro or Enterprise plan, without maintaining separate codebases per plan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A/B testing.&lt;/strong&gt; Show two variants of a feature to different user segments and measure which one performs better.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Beta programs.&lt;/strong&gt; Let specific users (or your own team) try a feature early, without exposing it to everyone.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; These use cases usually map to four flag &lt;em&gt;types&lt;/em&gt;: &lt;strong&gt;release flags&lt;/strong&gt; (hide unfinished work), &lt;strong&gt;ops flags&lt;/strong&gt; (kill switches), &lt;strong&gt;permission flags&lt;/strong&gt; (plan-based access), and &lt;strong&gt;experiment flags&lt;/strong&gt; (A/B tests). Naming your flags after their type helps keep a growing flag list organized — more on this in the best-practices section.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;This guide uses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supabase&lt;/strong&gt; as the database (the same pattern works with any Postgres/SQL database — just swap the client)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the tiny UI examples&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We'll build two versions of the same thing:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A do-it-yourself, database-backed flag system&lt;/strong&gt; — no extra service, full control, great for learning how flags actually work under the hood.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Vercel Flags SDK&lt;/strong&gt; — an official, open-source toolkit purpose-built for Next.js that adds typed flags, a local override toolbar, and a clean pattern for swapping providers later.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You don't need both — pick whichever fits your project, or read both to understand the tradeoffs.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;  User Request
      │
      ▼
Server Component
      │
      ▼
getFeatureFlag()
      │
      ▼
Next.js Cache
      │
      ├── Cache Hit ─────► true / false
      │
      ▼
  Supabase
      │
      ▼
 true / false
      │
      ▼
  Render UI
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-1-building-your-own-feature-flag-system&quot;&gt;Part 1: Building Your Own Feature Flag System&lt;/h3&gt;
&lt;p&gt;This is the version to reach for if you want zero extra dependencies and full control over where flag data lives — a great fit if you're already using Supabase for your app's database.&lt;/p&gt;
&lt;h4 id=&quot;step-1-create-the-feature-flags-table&quot;&gt;Step 1: Create the Feature Flags Table&lt;/h4&gt;
&lt;p&gt;Every flag needs a &lt;strong&gt;key&lt;/strong&gt; (a unique name), whether it's &lt;strong&gt;enabled&lt;/strong&gt;, and optionally a &lt;strong&gt;rollout percentage&lt;/strong&gt; for gradual releases.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Run this in the Supabase SQL editor
create table public.feature_flags (
  key text primary key,
  enabled boolean not null default false,
  rollout_percentage int not null default 100
  check (rollout_percentage between 0 and 100),
  description text,
  updated_at timestamptz not null default now()
);

-- Keep RLS on with no policies — only a service-role client can read this,
-- not even your normal server-side (cookie-based) Supabase client, and never the anon key.
alter table public.feature_flags enable row level security;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;key&lt;/code&gt; is a plain text primary key like &lt;code&gt;&quot;new-checkout&quot;&lt;/code&gt; — your code will reference flags by this string. &lt;code&gt;enabled&lt;/code&gt; is the master on/off switch. &lt;code&gt;rollout_percentage&lt;/code&gt; lets you release to a slice of users instead of everyone at once (100 means &quot;everyone who has the flag enabled sees it,&quot; 25 means &quot;only about a quarter of them do&quot;). Enabling Row Level Security with no policies means this table isn't readable through the public API at all — only your server-side Supabase client (which uses full database access) can read it, which is exactly what you want for something that controls app behavior.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Leaving this table world-readable through the anon key. Even though flag values aren't usually &quot;secret&quot; in the sensitive-data sense, exposing your entire flag list (including unreleased feature names) to anyone poking at your API is an easy, avoidable leak.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Add a couple of test rows:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;insert into public.feature_flags (key, enabled, rollout_percentage, description)
values
  ('new-checkout', true, 100, 'Redesigned checkout flow'),
  ('ai-summary', true, 20, 'AI-generated summary panel (early rollout)');
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-2-write-a-deterministic-rollout-function&quot;&gt;Step 2: Write a Deterministic Rollout Function&lt;/h4&gt;
&lt;p&gt;If &lt;code&gt;rollout_percentage&lt;/code&gt; is 20, you want the &lt;em&gt;same&lt;/em&gt; 20% of users to see the feature every time they visit — not a random 20% on every page load. That requires a &lt;strong&gt;deterministic&lt;/strong&gt; check: given the same user and the same flag, always return the same answer.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/flags/rollout.ts
import { createHash } from &quot;crypto&quot;;

/**
 * Deterministically decides whether a given user falls inside a flag's
 * rollout percentage. The same userId + flagKey combination always
 * produces the same result, so a user doesn't flicker between the old
 * and new experience on every request.
 */
export function isInRollout(
  userId: string,
  flagKey: string,
  percentage: number
): boolean {
  if (percentage &amp;gt;= 100) return true;
  if (percentage &amp;lt;= 0) return false;

  const hash = createHash(&quot;sha256&quot;)
    .update(`${flagKey}:${userId}`)
    .digest(&quot;hex&quot;);

  // Take the first 8 hex characters, turn them into a number,
  // and reduce it to a bucket between 0 and 99.
  const bucket = parseInt(hash.slice(0, 8), 16) % 100;

  return bucket &amp;lt; percentage;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;createHash(&quot;sha256&quot;)&lt;/code&gt; hashes the combination of the flag key and the user's ID. Because hashing is deterministic (the same input always produces the same output), the same user always lands in the same &quot;bucket&quot; (a number from 0–99) for a given flag. If that bucket number is less than the rollout percentage, they see the feature. Since we mix in the flag key too, the same user can be in the &quot;in&quot; bucket for one flag and the &quot;out&quot; bucket for a completely different flag — buckets aren't shared across flags.&lt;/p&gt;
&lt;h4 id=&quot;step-3-create-a-server-only-function-to-read-a-flag&quot;&gt;Step 3: Create a Server-Only Function to Read a Flag&lt;/h4&gt;
&lt;h5 id=&quot;first-create-a-service-role-supabase-client&quot;&gt;First create a service-role Supabase client&lt;/h5&gt;
&lt;p&gt;The feature_flags table has RLS enabled with no policies, so it can't be read by any client except one authenticated with your service role key — not even your normal server-side (cookie-based) Supabase client can see it. That's actually a good fit here, since flags are app-level configuration, not user data, and it also happens to be the client we need for caching: it has no cookies or per-request session attached, which Next.js requires for anything wrapped in &lt;code&gt;&quot;use cache&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/service-client.ts
import { createClient } from &quot;@supabase/supabase-js&quot;;

// Service-role client — bypasses RLS, no cookies/session attached.
// Only ever use this for trusted, server-only reads like feature flags.
// Never import it into a Client Component, and never prefix the key with NEXT_PUBLIC_.
export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Leaking the service role key to the browser. It must stay a plain (non-&lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;) environment variable, set only in your server environment — this key bypasses every RLS policy in your database, so treat it with the same care as a database password.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/flags/get-flag.ts
import { supabaseAdmin } from &quot;@/lib/supabase/service-client&quot;;
import { isInRollout } from &quot;./rollout&quot;;

/**
 * Reads a feature flag's value for a given user.
 * Server-only — never call this from a Client Component.
 */
export async function getFeatureFlag(
  key: string,
  userId?: string
): Promise&amp;lt;boolean&amp;gt; {
  const { data, error } = await supabaseAdmin
    .from(&quot;feature_flags&quot;)
    .select(&quot;enabled, rollout_percentage&quot;)
    .eq(&quot;key&quot;, key)
    .maybeSingle();

  // Fail safe: an unknown or errored flag defaults to &quot;off&quot;,
  // not &quot;on&quot; — a missing flag should never accidentally expose
  // an unfinished feature to everyone.
  if (error || !data || !data.enabled) {
    return false;
  }

  // No user context (e.g. a public page) — just use the master switch.
  if (!userId) {
    return data.rollout_percentage &amp;gt;= 100;
  }

  return isInRollout(userId, key, data.rollout_percentage);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; this function is the single place that knows how to read a flag — every page, Server Action, or Route Handler in your app calls through here instead of querying Supabase directly. Notice the fail-safe default: if the flag doesn't exist, or the database call errors out, we return &lt;code&gt;false&lt;/code&gt;. That's a deliberate choice — a flag failing &quot;closed&quot; (off) is almost always safer than failing &quot;open&quot; (on), since an unexpected &lt;code&gt;true&lt;/code&gt; could expose an unfinished feature to every user.&lt;/p&gt;
&lt;h4 id=&quot;caching-feature-flag-reads-with-next-cache&quot;&gt;Caching Feature Flag Reads with &lt;code&gt;next/cache&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Right now, &lt;code&gt;getFeatureFlag()&lt;/code&gt; hits Supabase on every single call. If a flag is checked on a high-traffic page — or several times per request, once per feature — that's a lot of avoidable database round trips for data that barely changes. A hand-rolled in-memory cache can fix that, but it comes with real downsides in production: it only lives inside one serverless function instance (so different instances can disagree), and it disappears completely on every cold start or redeploy. Next.js already ships a caching layer built for exactly this — reach for that instead of reinventing it.&lt;/p&gt;
&lt;p&gt;The good news: the &lt;em&gt;shared&lt;/em&gt; part of a flag (its &lt;code&gt;enabled&lt;/code&gt; state and &lt;code&gt;rollout_percentage&lt;/code&gt;) is identical for every user. Only the final true/false decision is per-user, and that part is just cheap in-memory hashing. So you only need to cache the database row, not the per-user result.&lt;/p&gt;
&lt;h5 id=&quot;caching-the-flag-row&quot;&gt;Caching the flag row&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/flags/get-flag.ts
import { cacheLife, cacheTag } from &quot;next/cache&quot;;
import { supabaseAdmin } from &quot;@/lib/supabase/service-client&quot;;
import { isInRollout } from &quot;./rollout&quot;;

type FlagRow = { enabled: boolean; rollout_percentage: number };

async function fetchFlagRow(key: string): Promise&amp;lt;FlagRow | null&amp;gt; {
  &quot;use cache&quot;;
  cacheLife(&quot;minutes&quot;); // safety-net expiry if nothing ever invalidates it
  cacheTag(`feature-flag:${key}`); // lets us invalidate just this one flag

  const { data, error } = await supabaseAdmin
    .from(&quot;feature_flags&quot;)
    .select(&quot;enabled, rollout_percentage&quot;)
    .eq(&quot;key&quot;, key)
    .maybeSingle();

  return error ? null : data;
}

export async function getFeatureFlag(
  key: string,
  userId?: string
): Promise&amp;lt;boolean&amp;gt; {
  const row = await fetchFlagRow(key);

  if (!row || !row.enabled) return false;
  if (!userId) return row.rollout_percentage &amp;gt;= 100;

  return isInRollout(userId, key, row.rollout_percentage);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;&quot;use cache&quot;&lt;/code&gt; at the top of &lt;code&gt;fetchFlagRow&lt;/code&gt; marks that function as a cache boundary — Next.js automatically derives the cache key from the function's arguments (&lt;code&gt;key&lt;/code&gt;), so each flag gets its own independent cache entry with no manual key-building required. &lt;code&gt;cacheLife(&quot;minutes&quot;)&lt;/code&gt; applies one of Next.js's built-in expiry profiles as a fallback expiry (a handful of minutes), in case nothing ever explicitly invalidates it. &lt;code&gt;cacheTag(\&lt;/code&gt;feature-flag:${key}`)` is the important part for production use — it lets you invalidate exactly &lt;em&gt;one&lt;/em&gt; flag on demand instead of waiting for the fallback expiry or flushing every cached flag at once.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; &lt;code&gt;&quot;use cache&quot;&lt;/code&gt; requires Cache Components to be enabled in &lt;code&gt;next.config.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const nextConfig: NextConfig = {
  cacheComponents: true,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your project isn't on Cache Components yet, use the older &lt;code&gt;unstable_cache&lt;/code&gt; API instead — same tagging idea, just a wrapper function instead of a directive:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { unstable_cache } from &quot;next/cache&quot;;

const fetchFlagRow = (key: string) =&amp;gt;
  unstable_cache(
    async () =&amp;gt; {
      const { data, error } = await supabaseAdmin
        .from(&quot;feature_flags&quot;)
        .select(&quot;enabled, rollout_percentage&quot;)
        .eq(&quot;key&quot;, key)
        .maybeSingle();
      return error ? null : data;
    },
    [&quot;feature-flag&quot;, key],
    { tags: [`feature-flag:${key}`], revalidate: 60 }
  )();
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;h5 id=&quot;invalidating-a-flag-the-moment-it-changes&quot;&gt;Invalidating a flag the moment it changes&lt;/h5&gt;
&lt;p&gt;Caching only pays off if flipping a flag actually takes effect quickly. Instead of waiting out &lt;code&gt;cacheLife&lt;/code&gt;'s fallback window, invalidate the specific tag the moment an admin updates the row:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/toggle-feature-flag.ts
&quot;use server&quot;;

import { revalidateTag } from &quot;next/cache&quot;;
import { supabaseAdmin } from &quot;@/lib/supabase/service-client&quot;;

export async function toggleFeatureFlag(key: string, enabled: boolean) {
  const { error } = await supabaseAdmin
    .from(&quot;feature_flags&quot;)
    .update({ enabled, updated_at: new Date().toISOString() })
    .eq(&quot;key&quot;, key);

  if (error) {
    return { error: error.message };
  }

  // Instantly invalidates only this flag's cached row — every
  // subsequent getFeatureFlag() call re-fetches fresh data.
  revalidateTag(`feature-flag:${key}`);

  return { success: true };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;revalidateTag(\&lt;/code&gt;feature-flag:${key}`)&lt;code&gt; tells Next.js's cache &quot;anything tagged with this string is now stale&quot; — the very next call to &lt;/code&gt;fetchFlagRow(key)&lt;code&gt; skips the cache and re-fetches from Supabase, then caches the fresh result under the same tag. This is what makes a cached flag system still behave like an instant kill switch: the cache saves you database round trips on the read path, while &lt;/code&gt;revalidateTag` guarantees a write is reflected immediately, not after some fixed delay.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Updating the &lt;code&gt;feature_flags&lt;/code&gt; table directly from the Supabase dashboard and expecting the change to show up right away. The cache has no way to know the row changed unless something calls &lt;code&gt;revalidateTag()&lt;/code&gt; — always flip flags through a path (like the Server Action above) that revalidates the tag, or expect to wait out the &lt;code&gt;cacheLife&lt;/code&gt; window.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-4-use-the-flag-in-a-server-component&quot;&gt;Step 4: Use the Flag in a Server Component&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
import { getFeatureFlag } from &quot;@/lib/flags/get-flag&quot;;
import { getCurrentUser } from &quot;@/lib/auth&quot;; // whatever your auth setup returns
import { NewDashboard } from &quot;@/components/dashboard/new-dashboard&quot;;
import { OldDashboard } from &quot;@/components/dashboard/old-dashboard&quot;;

export default async function DashboardPage() {
  const user = await getCurrentUser();
  const showNewDashboard = await getFeatureFlag(&quot;new-dashboard&quot;, user?.id);

  return showNewDashboard ? &amp;lt;NewDashboard /&amp;gt; : &amp;lt;OldDashboard /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; because &lt;code&gt;DashboardPage&lt;/code&gt; is an &lt;code&gt;async&lt;/code&gt; Server Component, we can &lt;code&gt;await getFeatureFlag(...)&lt;/code&gt; directly during render — no loading spinner, no client-side fetch, no flicker between the two versions. The flag is resolved once, on the server, before any HTML reaches the browser.&lt;/p&gt;
&lt;h4 id=&quot;step-5-use-the-same-flag-in-a-server-action&quot;&gt;Step 5: Use the Same Flag in a Server Action&lt;/h4&gt;
&lt;p&gt;Flags aren't just for showing/hiding UI — sometimes you want to gate a whole action, like a new AI feature that costs you money per call.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/generate-summary.ts
&quot;use server&quot;;

import { getFeatureFlag } from &quot;@/lib/flags/get-flag&quot;;
import { getCurrentUser } from &quot;@/lib/auth&quot;;

export async function generateSummary(text: string) {
  const user = await getCurrentUser();

  if (!user) {
    return { error: &quot;You must be signed in.&quot; };
  }

  const enabled = await getFeatureFlag(&quot;ai-summary&quot;, user.id);

  if (!enabled) {
    return { error: &quot;This feature isn't available yet.&quot; };
  }

  // ... call your AI provider here
  return { summary: &quot;...&quot; };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; re-checking the flag inside the Server Action matters even though the button that triggers it is already hidden by the flag in the UI. A hidden button doesn't stop someone from calling the Server Action directly — the server-side check is your real gate, the UI check is just for user experience.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Only checking a flag in the UI (hiding a button) and assuming that's enough. Server Actions are callable endpoints under the hood — always re-verify anything sensitive on the server, the same way you would with authentication or authorization checks.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Related: Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-2-using-the-vercel-flags-sdk&quot;&gt;Part 2: Using the Vercel Flags SDK&lt;/h3&gt;
&lt;p&gt;The do-it-yourself version above works fine, but as your flag list grows you'll want a few things it doesn't give you: a typed, centralized list of every flag in your app, a way to preview flag values locally without touching the database, and a consistent pattern if you ever want to swap in a dedicated flag provider later.&lt;/p&gt;
&lt;p&gt;That's exactly what the &lt;strong&gt;Flags SDK&lt;/strong&gt; (built by the Vercel/Next.js team) solves. It's free, open source, framework-native for Next.js, and — importantly — it works with &lt;em&gt;any&lt;/em&gt; data source, including the Supabase table you just built. You're not locked into a specific vendor.&lt;/p&gt;
&lt;h4 id=&quot;step-1-install-the-sdk&quot;&gt;Step 1: Install the SDK&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install flags
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-2-generate-a-flags-secret&quot;&gt;Step 2: Generate a Flags Secret&lt;/h4&gt;
&lt;p&gt;The SDK uses a secret key to encrypt flag values when they're referenced by its local override tooling. Generate one with Node:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;node -e &quot;console.log(crypto.randomBytes(32).toString('base64url'))&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Copy the output into your environment variables:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# .env.local
FLAGS_SECRET=your-generated-secret-here
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Use a different &lt;code&gt;FLAGS_SECRET&lt;/code&gt; value for each environment (development, preview, production). If you're deploying on Vercel, set each one with &lt;code&gt;vercel env add FLAGS_SECRET production --sensitive&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-3-define-your-flags-in-one-file&quot;&gt;Step 3: Define Your Flags in One File&lt;/h4&gt;
&lt;p&gt;This is the part that makes the SDK worth using — every flag in your app is declared once, as a typed function, in a single file.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// flags.ts
import { flag } from &quot;flags/next&quot;;
import { getFeatureFlag } from &quot;@/lib/flags/get-flag&quot;;
import { getCurrentUser } from &quot;@/lib/auth&quot;;

export const newDashboardFlag = flag&amp;lt;boolean&amp;gt;({
  key: &quot;new-dashboard&quot;,
  description: &quot;Show the redesigned dashboard layout&quot;,
  decide: async () =&amp;gt; {
    const user = await getCurrentUser();
    return getFeatureFlag(&quot;new-dashboard&quot;, user?.id);
  },
});

export const aiSummaryFlag = flag&amp;lt;boolean&amp;gt;({
  key: &quot;ai-summary&quot;,
  description: &quot;AI-generated summary panel (early rollout)&quot;,
  decide: async () =&amp;gt; {
    const user = await getCurrentUser();
    return getFeatureFlag(&quot;ai-summary&quot;, user?.id);
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;flag()&lt;/code&gt; wraps each feature into its own reusable, typed function. Notice the &lt;code&gt;decide&lt;/code&gt; function is just calling the exact same &lt;code&gt;getFeatureFlag()&lt;/code&gt; helper you already built in Part 1 — the SDK doesn't replace your data source, it sits on top of it. This means you get the SDK's developer experience (typed flags, the local toolbar, easy provider swaps later) while still owning your data in Supabase.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Turning every flag into its own exported function means your editor's &quot;Find All References&quot; feature works — you can instantly see every place a flag is used, and confidently delete it once it's no longer needed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-4-use-a-flag-in-a-page&quot;&gt;Step 4: Use a Flag in a Page&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
import { newDashboardFlag } from &quot;@/flags&quot;;
import { NewDashboard } from &quot;@/components/dashboard/new-dashboard&quot;;
import { OldDashboard } from &quot;@/components/dashboard/old-dashboard&quot;;

export default async function DashboardPage() {
  const showNewDashboard = await newDashboardFlag();

  return showNewDashboard ? &amp;lt;NewDashboard /&amp;gt; : &amp;lt;OldDashboard /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; calling &lt;code&gt;newDashboardFlag()&lt;/code&gt; runs the &lt;code&gt;decide&lt;/code&gt; function you defined in &lt;code&gt;flags.ts&lt;/code&gt; and returns the resolved boolean. Notice there are no arguments at the call site — the flag function already knows how to figure out the current user internally. This is intentional: it keeps every call site simple and makes flags easy to reason about, since the logic for &lt;em&gt;how&lt;/em&gt; a flag is decided lives in exactly one place.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Trying to call a flag function from a Client Component. Flags SDK flags are server-only by design — this avoids client-side loading spinners and keeps the decision logic (which might touch a database or user session) off the browser entirely. If a Client Component needs the value, resolve it in the parent Server Component and pass it down as a prop.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-5-conditionally-render-a-client-component&quot;&gt;Step 5: Conditionally Render a Client Component&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
import { aiSummaryFlag } from &quot;@/flags&quot;;
import { SummaryPanel } from &quot;@/components/dashboard/summary-panel&quot;;

export default async function DashboardPage() {
  const showAiSummary = await aiSummaryFlag();

  return (
    &amp;lt;div className=&quot;space-y-6&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-2xl font-semibold&quot;&amp;gt;Dashboard&amp;lt;/h1&amp;gt;
      {showAiSummary &amp;amp;&amp;amp; &amp;lt;SummaryPanel /&amp;gt;}
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/dashboard/summary-panel.tsx
&quot;use client&quot;;

export function SummaryPanel() {
  // Interactive logic here — the flag decision already
  // happened on the server before this component ever rendered.
  return (
    &amp;lt;div className=&quot;rounded-xl border p-4&quot;&amp;gt;
      &amp;lt;p className=&quot;text-sm text-muted-foreground&quot;&amp;gt;AI summary coming soon…&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; the Server Component resolves the flag once and only renders &lt;code&gt;&amp;lt;SummaryPanel /&amp;gt;&lt;/code&gt; at all if the flag is on. The Client Component itself never needs to know a flag exists — it just renders when it's asked to. This keeps the flag-checking logic entirely on the server, which is both simpler and safer than shipping the flag's raw value to the browser.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;best-practices-for-feature-flags&quot;&gt;Best Practices for Feature Flags&lt;/h3&gt;
&lt;p&gt;A handful of habits that keep a growing flag system from turning into a mess:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Name flags consistently.&lt;/strong&gt; Use a predictable pattern like &lt;code&gt;kebab-case&lt;/code&gt; with a type hint, e.g. &lt;code&gt;release-new-checkout&lt;/code&gt;, &lt;code&gt;experiment-pricing-copy&lt;/code&gt;, &lt;code&gt;ops-disable-emails&lt;/code&gt;, &lt;code&gt;permission-ai-summary&lt;/code&gt;. It makes a flag's purpose obvious at a glance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Default to &quot;off&quot; everywhere.&lt;/strong&gt; As shown in &lt;code&gt;getFeatureFlag()&lt;/code&gt;, an unknown, missing, or errored flag should resolve to &lt;code&gt;false&lt;/code&gt;. Fail closed, not open.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delete flags once they've served their purpose.&lt;/strong&gt; A release flag that's been at 100% for a month and never gets turned off isn't a flag anymore — it's just a permanent &lt;code&gt;if&lt;/code&gt; statement adding complexity. Remove it and the dead code branch behind it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never gate real security on a flag alone.&lt;/strong&gt; A feature flag controls &lt;em&gt;visibility&lt;/em&gt;, not &lt;em&gt;authorization&lt;/em&gt;. Someone technically could still reach a hidden feature by calling the underlying Server Action or API route directly — always pair a flag with a real permission check for anything sensitive (see Step 5 in Part 1).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test both states.&lt;/strong&gt; Before shipping, manually verify your app behaves correctly with the flag both on &lt;em&gt;and&lt;/em&gt; off — it's easy to only test the &quot;on&quot; path and forget the fallback still needs to work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the rollout logic deterministic.&lt;/strong&gt; As covered in Part 1, a user should see a consistent experience across requests, not a coin flip on every page load.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;     Create
       ↓
    Develop
       ↓
Internal Testing
       ↓
       5%
       ↓
      25%
       ↓
      50%
       ↓
     100%
       ↓
    Delete
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;do-i-need-a-third-party-service-like-launchdarkly-to-use-feature-flags-&quot;&gt;Do I need a third-party service like LaunchDarkly to use feature flags?&lt;/h4&gt;
&lt;p&gt;No. A single database table, like the &lt;code&gt;feature_flags&lt;/code&gt; table built in Part 1, is enough for most SaaS apps — especially early on. Third-party feature flag platforms add real value once you need advanced targeting rules, detailed analytics per flag, or a non-technical team managing flags through a dashboard, but they're not a requirement to get started.&lt;/p&gt;
&lt;h4 id=&quot;what-s-the-difference-between-a-feature-flag-and-an-environment-variable-&quot;&gt;What's the difference between a feature flag and an environment variable?&lt;/h4&gt;
&lt;p&gt;An environment variable is set at build or deploy time and requires a redeploy to change. A feature flag is checked at runtime and can be flipped instantly without touching your deployment — that's the whole point. Use environment variables for configuration (API keys, database URLs) and feature flags for behavior that needs to change without a redeploy.&lt;/p&gt;
&lt;h4 id=&quot;can-i-use-feature-flags-for-a-b-testing-&quot;&gt;Can I use feature flags for A/B testing?&lt;/h4&gt;
&lt;p&gt;Yes — this is one of the most common uses. Instead of a simple on/off boolean, an experiment flag typically returns one of several variants (e.g. &lt;code&gt;&quot;control&quot;&lt;/code&gt; or &lt;code&gt;&quot;variant-b&quot;&lt;/code&gt;), and you track which variant a user saw alongside your analytics events to measure the difference in behavior.&lt;/p&gt;
&lt;h4 id=&quot;do-feature-flags-have-to-be-true-false-&quot;&gt;Do feature flags have to be true/false?&lt;/h4&gt;
&lt;p&gt;No — boolean is just the simplest case. Many production flags are &lt;strong&gt;multivariate&lt;/strong&gt;: instead of resolving to &lt;code&gt;true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt;, they resolve to one of several named values, like &lt;code&gt;&quot;control&quot;&lt;/code&gt;, &lt;code&gt;&quot;variant-a&quot;&lt;/code&gt;, or &lt;code&gt;&quot;variant-b&quot;&lt;/code&gt;. This is what powers most pricing, copy, and layout experiments, where you're comparing more than two options at once. The pattern in this guide extends naturally — swap the &lt;code&gt;enabled&lt;/code&gt; boolean column for a &lt;code&gt;value&lt;/code&gt; column (&lt;code&gt;text&lt;/code&gt; or &lt;code&gt;jsonb&lt;/code&gt;), and type your Flags SDK flag as &lt;code&gt;flag&amp;lt;string&amp;gt;({...})&lt;/code&gt; instead of &lt;code&gt;flag&amp;lt;boolean&amp;gt;({...})&lt;/code&gt; so &lt;code&gt;decide()&lt;/code&gt; returns the variant name instead of a boolean. Everything else — the deterministic bucketing, the caching, the server-only checks — works exactly the same way.&lt;/p&gt;
&lt;h4 id=&quot;are-feature-flags-secure-enough-to-gate-paid-features-&quot;&gt;Are feature flags secure enough to gate paid features?&lt;/h4&gt;
&lt;p&gt;A flag can &lt;em&gt;decide&lt;/em&gt; whether to show a paid feature, but it should never be the only check. Always verify the user's actual subscription/permission status on the server (inside the Server Action or API route that does the real work) — the flag is a UX layer on top of that, not a replacement for it.&lt;/p&gt;
&lt;h4 id=&quot;how-many-feature-flags-is-too-many-&quot;&gt;How many feature flags is too many?&lt;/h4&gt;
&lt;p&gt;There's no hard number, but if you regularly can't remember what a flag does or find yourself afraid to delete one, that's a sign your flag list needs cleanup. A good habit is reviewing flags monthly and removing any release flag that's been fully rolled out and stable for a few weeks.&lt;/p&gt;
&lt;h4 id=&quot;does-checking-a-flag-slow-down-my-page-&quot;&gt;Does checking a flag slow down my page?&lt;/h4&gt;
&lt;p&gt;If you're checking the same flags on every request, the Next.js caching approach from the &lt;strong&gt;Caching Feature Flag Reads with &lt;code&gt;next/cache&lt;/code&gt;&lt;/strong&gt; section removes most of that cost while still allowing immediate invalidation with &lt;code&gt;revalidateTag()&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-1&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Feature flags turn deployment and release into two separate decisions — you can ship code to production without exposing it, roll features out gradually, and flip a broken feature off in seconds instead of scrambling through a redeploy. You've now got two working approaches: a simple, fully self-owned system backed by a Supabase table, and the Vercel Flags SDK layered on top of it for a more polished, typed developer experience.&lt;/p&gt;
&lt;p&gt;Start small — pick one risky upcoming feature, put it behind a flag using the Part 1 pattern, and get comfortable with the workflow before rolling flags out across your whole app.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning-1&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-supabase-todo-app&quot;&gt;Build a Todo App with Next.js 16 and Supabase (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25162/feature-flags-in-saas-a-beginners-guide-with-next-js-16-2026</guid>
<pubDate>Sun, 23 Aug 2026 05:03:07 +0000</pubDate>
</item>
<item>
<title>Supabase Auth vs Firebase Auth: Which One Should You Choose in 2026?</title>
<link>https://coderlegion.com/24980/supabase-auth-vs-firebase-auth-which-one-should-you-choose-in-2026</link>
<description>&lt;p&gt;You're starting a new Next.js project, and before you write a single feature, you have to answer one boring but critical question: &lt;strong&gt;who's going to handle login?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Two names keep showing up at the top of every list — &lt;strong&gt;Supabase Auth&lt;/strong&gt; and &lt;strong&gt;Firebase Auth&lt;/strong&gt;. Both are free to start, both support Google/GitHub/email login, and both promise to save you from writing your own password-hashing logic. But they're built on completely different foundations, and picking the wrong one can mean months of awkward workarounds later — like discovering you need SQL joins for a permissions system, but you're stuck with Firestore's document model.&lt;/p&gt;
&lt;p&gt;This guide breaks down exactly how Supabase Auth and Firebase Auth differ in 2026 — architecture, pricing, security, and real Next.js 16 App Router code for both — so you can pick the right one &lt;em&gt;before&lt;/em&gt; you've built half your app on top of it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-comparison-is-tricky&quot;&gt;Why This Comparison Is Tricky&lt;/h3&gt;
&lt;p&gt;Most &quot;Supabase vs Firebase&quot; posts compare marketing pages, not real implementation experience. The truth is these two products solve authentication very differently under the hood:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Supabase Auth&lt;/strong&gt; is a layer on top of &lt;strong&gt;PostgreSQL&lt;/strong&gt; — every user lives in a real SQL table (&lt;code&gt;auth.users&lt;/code&gt;), and permissions are enforced with &lt;strong&gt;Row Level Security (RLS)&lt;/strong&gt; policies written in SQL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firebase Auth&lt;/strong&gt; is a layer on top of &lt;strong&gt;Firestore/Google Cloud&lt;/strong&gt; — users live in Google's own auth system, and permissions are enforced with &lt;strong&gt;Firestore Security Rules&lt;/strong&gt;, a completely different (non-SQL) rules language.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That single architectural difference is what actually decides which one fits your project — not the login form UI, which looks nearly identical on both.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;quick-answer&quot;&gt;Quick Answer&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Choose Supabase Auth&lt;/strong&gt; if you want a real Postgres database, SQL-based permissions (RLS), open-source flexibility, and the option to self-host later.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose Firebase Auth&lt;/strong&gt; if you're already invested in Firebase or Google Cloud services, or if Firebase's managed identity infrastructure fits your application's architecture.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now let's get into the details.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;supabase-auth-vs-firebase-auth-which-is-better-for-next-js-&quot;&gt;Supabase Auth vs Firebase Auth: Which Is Better for Next.js?&lt;/h3&gt;
&lt;p&gt;If you're building a Next.js SaaS application with PostgreSQL, Supabase Auth is usually the more natural choice because authentication, database access, and authorization can be designed around the same Postgres/RLS model.&lt;/p&gt;
&lt;p&gt;Firebase Auth is a better fit when your application already uses Firebase/Google Cloud services or your data model naturally fits Firestore.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;supabase-auth-vs-firebase-auth-feature-comparison&quot;&gt;Supabase Auth vs Firebase Auth: Feature Comparison&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Feature &lt;/th&gt;&lt;th&gt;Supabase Auth &lt;/th&gt;&lt;th&gt;Firebase Auth&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Authentication architecture &lt;/td&gt;&lt;td&gt;Integrated with Supabase/PostgreSQL ecosystem &lt;/td&gt;&lt;td&gt;Managed identity service&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Common database pairing     &lt;/td&gt;&lt;td&gt;PostgreSQL                                    &lt;/td&gt;&lt;td&gt;Firestore (NoSQL)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Free/no-cost auth tier &lt;/td&gt;&lt;td&gt;50,000 monthly active users (MAU) &lt;/td&gt;&lt;td&gt;Depends on Firebase Auth / Identity Platform configuration&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Paid pricing &lt;/td&gt;&lt;td&gt;Included in Supabase plan quota; $0.00325/MAU over Pro quota &lt;/td&gt;&lt;td&gt;Identity Platform: 50,000 MAU no-cost tier on Blaze, then usage-based pricing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Email/password &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Magic links &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅ (email link sign-in)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Social OAuth (Google, GitHub, etc.) &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Phone/SMS OTP &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Permissions model &lt;/td&gt;&lt;td&gt;Row Level Security (SQL policies) &lt;/td&gt;&lt;td&gt;Firestore Security Rules (custom rules language)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Self-hosting &lt;/td&gt;&lt;td&gt;✅ Yes (fully open source) &lt;/td&gt;&lt;td&gt;❌ No&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Open source &lt;/td&gt;&lt;td&gt;✅ Yes &lt;/td&gt;&lt;td&gt;❌ No&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Next.js SSR package &lt;/td&gt;&lt;td&gt;&lt;code&gt;@supabase/ssr&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Firebase JS SDK + &lt;code&gt;initializeServerApp&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Best paired with &lt;/td&gt;&lt;td&gt;Postgres-based SaaS apps &lt;/td&gt;&lt;td&gt;Google Cloud / Firestore apps&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Both platforms' free tiers are generous enough to cover most side projects and early-stage SaaS apps completely — pricing usually isn't the deciding factor until you're well past MVP stage.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;architecture-the-real-difference&quot;&gt;Architecture: The Real Difference&lt;/h3&gt;
&lt;p&gt;This is the part most comparison articles skip, and it's the one that actually matters.&lt;/p&gt;
&lt;h4 id=&quot;supabase-auth-runs-on-postgres&quot;&gt;Supabase Auth Runs on Postgres&lt;/h4&gt;
&lt;p&gt;When someone signs up with Supabase Auth, a row gets created in a special &lt;code&gt;auth.users&lt;/code&gt; table inside your actual Postgres database. Every other table you create — &lt;code&gt;profiles&lt;/code&gt;, &lt;code&gt;posts&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt; — can have a foreign key pointing straight at &lt;code&gt;auth.users.id&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That means your permissions logic is just SQL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Only let a user see their own orders
create policy &quot;Users can view own orders&quot;
on orders
for select
using (auth.uid() = user_id);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; if your app has relational data — a user has many orders, an order has many line items, a team has many members — Postgres joins handle that naturally. You're not fighting the database to model relationships.&lt;/p&gt;
&lt;h4 id=&quot;firebase-auth-and-firestore&quot;&gt;Firebase Auth And Firestore&lt;/h4&gt;
&lt;p&gt;Firebase Auth manages identity separately from your application data. When paired with Firestore, authorization is commonly enforced using &lt;strong&gt;Firestore Security Rules&lt;/strong&gt; — a rules language, not SQL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /orders/{orderId} {
      allow read: if request.auth != null &amp;amp;&amp;amp; request.auth.uid == resource.data.userId;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; Firestore is a document database. There's no native &lt;code&gt;JOIN&lt;/code&gt;. If your data is deeply relational (think: multi-tenant SaaS with teams, roles, and shared resources), you'll end up duplicating data across documents or running multiple round-trip queries to stitch things together client-side.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Neither model is &quot;wrong&quot; — Firestore's document model is genuinely great for chat apps, real-time feeds, and simple per-user data. Postgres shines once your data has real relationships between tables.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;setting-up-supabase-auth-in-next-js-16&quot;&gt;Setting Up Supabase Auth in Next.js 16&lt;/h3&gt;
&lt;p&gt;This guide assumes &lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router, &lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode, and &lt;strong&gt;Tailwind CSS&lt;/strong&gt; — the current defaults for any new Next.js project.&lt;/p&gt;
&lt;h4 id=&quot;step-1-install-dependencies&quot;&gt;Step 1: Install Dependencies&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @supabase/supabase-js @supabase/ssr
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;@supabase/supabase-js&lt;/code&gt; is the core client library. &lt;code&gt;@supabase/ssr&lt;/code&gt; is the package built specifically for frameworks like Next.js — it handles the tricky part of syncing auth cookies between the browser and the server so your session works correctly in Server Components, Server Actions, and the browser alike.&lt;/p&gt;
&lt;h4 id=&quot;step-2-add-environment-variables&quot;&gt;Step 2: Add Environment Variables&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ Never expose a Supabase secret key (&lt;code&gt;sb_secret_...&lt;/code&gt;) or legacy &lt;code&gt;service_role&lt;/code&gt; key in client-side code. Both bypass RLS.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; For new Supabase projects, use the publishable key rather than the legacy anon key. Supabase is transitioning away from the legacy API keys during 2026.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-3-create-the-browser-client&quot;&gt;Step 3: Create the Browser Client&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/client.ts
import { createBrowserClient } from &quot;@supabase/ssr&quot;;

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use this inside &lt;strong&gt;Client Components&lt;/strong&gt; — anywhere you need &lt;code&gt;&quot;use client&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;step-4-create-the-server-client&quot;&gt;Step 4: Create the Server Client&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/server.ts
import { createServerClient } from &quot;@supabase/ssr&quot;;
import { cookies } from &quot;next/headers&quot;;

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =&amp;gt;
              cookieStore.set(name, value, options)
            );
          } catch {
            // Safe to ignore when called from a Server Component
          }
        },
      },
    }
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; this client reads and writes auth cookies through Next.js's &lt;code&gt;cookies()&lt;/code&gt; API, so a session created during login is visible to Server Components and Server Actions immediately — no manual token passing needed.&lt;/p&gt;
&lt;h4 id=&quot;step-5-add-the-auth-proxy-middleware-&quot;&gt;Step 5: Add the Auth Proxy (Middleware)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// utils/supabase/proxy.ts
import { createServerClient } from &quot;@supabase/ssr&quot;;
import { NextResponse, type NextRequest } from &quot;next/server&quot;;

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet, headers) {
          cookiesToSet.forEach(({ name, value }) =&amp;gt;
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =&amp;gt;
            supabaseResponse.cookies.set(name, value, options)
          );
          Object.entries(headers).forEach(([key, value]) =&amp;gt;
            supabaseResponse.headers.set(key, value)
          );
        },
      },
    }
  );

  await supabase.auth.getClaims();

  return supabaseResponse;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// proxy.ts (root of project)
import { type NextRequest } from &quot;next/server&quot;;
import { updateSession } from &quot;@/utils/supabase/proxy&quot;;

export async function proxy(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    &quot;/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)&quot;,
  ],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Skipping this proxy file. Without it, Supabase's refresh token doesn't get renewed on every request, and users get randomly logged out mid-session — one of the most common Supabase Auth bugs beginners hit.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-6-sign-in-with-email-and-password&quot;&gt;Step 6: Sign In With Email and Password&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/auth.ts
&quot;use server&quot;;

import { redirect } from &quot;next/navigation&quot;;
import { createClient } from &quot;@/lib/supabase/server&quot;;

export async function login(prev: unknown, formData: FormData) {
  const supabase = await createClient();

  const { error } = await supabase.auth.signInWithPassword({
    email: formData.get(&quot;email&quot;) as string,
    password: formData.get(&quot;password&quot;) as string,
  });

  if (error) {
    return { error: error.message };
  }

  redirect(&quot;/dashboard&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-7-protect-data-with-row-level-security&quot;&gt;Step 7: Protect Data with Row Level Security&lt;/h4&gt;
&lt;p&gt;This is Supabase Auth's biggest advantage — security lives at the &lt;strong&gt;database level&lt;/strong&gt;, not just in your app code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;alter table profiles enable row level security;

create policy &quot;Users can view own profile&quot;
on profiles for select
using ((select auth.uid()) = id);

create policy &quot;Users can update own profile&quot;
on profiles for update
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; even if someone bypasses your Next.js app entirely and hits the Supabase API directly, RLS still blocks them from seeing data that isn't theirs. Your frontend code stops being the only line of defense.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 If you're using Supabase Auth with multiple tables, writing RLS policies manually can become repetitive. DevStacked's &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; can generate strict policies based on your access pattern.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;setting-up-firebase-auth-in-next-js-16&quot;&gt;Setting Up Firebase Auth in Next.js 16&lt;/h3&gt;
&lt;h4 id=&quot;step-1-install-dependencies-1&quot;&gt;Step 1: Install Dependencies&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install firebase firebase-admin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;firebase&lt;/code&gt; is the client SDK (runs in the browser and in Server Components via Firebase's newer server app support). &lt;code&gt;firebase-admin&lt;/code&gt; is for privileged, server-only operations like verifying tokens.&lt;/p&gt;
&lt;h4 id=&quot;step-2-add-environment-variables-1&quot;&gt;Step 2: Add Environment Variables&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# .env.local
NEXT_PUBLIC_FIREBASE_API_KEY=your_api_key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=yourproject.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=yourproject
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-3-initialize-firebase-client-side-&quot;&gt;Step 3: Initialize Firebase (Client Side)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/firebase/client.ts
import { initializeApp, getApps, getApp } from &quot;firebase/app&quot;;
import { getAuth } from &quot;firebase/auth&quot;;

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
};

const app = getApps().length ? getApp() : initializeApp(firebaseConfig);

export const auth = getAuth(app);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;getApps().length ? getApp() : initializeApp(...)&lt;/code&gt; prevents Firebase from being re-initialized on every hot reload in development — a classic Firebase + Next.js gotcha if you skip this check.&lt;/p&gt;
&lt;h4 id=&quot;step-4-sign-in-with-email-and-password&quot;&gt;Step 4: Sign In With Email and Password&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/login-form.tsx
&quot;use client&quot;;

import { signInWithEmailAndPassword } from &quot;firebase/auth&quot;;
import { auth } from &quot;@/lib/firebase/client&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { useState } from &quot;react&quot;;

export function LoginForm() {
  const router = useRouter();
  const [error, setError] = useState(&quot;&quot;);

  const handleSubmit = async (e: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
    e.preventDefault();
    const form = new FormData(e.currentTarget);

    try {
      await signInWithEmailAndPassword(
        auth,
        form.get(&quot;email&quot;) as string,
        form.get(&quot;password&quot;) as string
      );
      router.push(&quot;/dashboard&quot;);
    } catch (err) {
      setError(err instanceof Error ? err.message : &quot;Login failed&quot;);
    }
  };

  return (
    &amp;lt;form onSubmit={handleSubmit}&amp;gt;
      &amp;lt;input name=&quot;email&quot; type=&quot;email&quot; required /&amp;gt;
      &amp;lt;input name=&quot;password&quot; type=&quot;password&quot; required /&amp;gt;
      &amp;lt;button type=&quot;submit&quot;&amp;gt;Sign in&amp;lt;/button&amp;gt;
      {error &amp;amp;&amp;amp; &amp;lt;p&amp;gt;{error}&amp;lt;/p&amp;gt;}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Doing all your auth logic client-side and assuming that's enough for SSR. Firebase's client SDK doesn't automatically make the logged-in user visible to your Next.js Server Components — you need a separate step to bridge that gap (next).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-5-bridge-the-session-to-the-server&quot;&gt;Step 5: Bridge the Session to the Server&lt;/h4&gt;
&lt;p&gt;Firebase's newer &lt;strong&gt;Firebase Server App&lt;/strong&gt; feature lets you read the authenticated user inside Server Components:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/firebase/server.ts
import { cookies } from &quot;next/headers&quot;;
import { initializeApp, initializeServerApp } from &quot;firebase/app&quot;;
import { getAuth } from &quot;firebase/auth&quot;;

export async function getAuthenticatedAppForUser() {
  const authIdToken = (await cookies()).get(&quot;__session&quot;)?.value;

  const firebaseServerApp = initializeServerApp(initializeApp(), {
    authIdToken,
  });

  const auth = getAuth(firebaseServerApp);
  await auth.authStateReady();

  return { firebaseServerApp, currentUser: auth.currentUser };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Learn more &lt;code&gt;Firebase Server App&lt;/code&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://firebase.google.com/codelabs/firebase-nextjs#5&quot;&gt;here&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; the client sets a &lt;code&gt;__session&lt;/code&gt; cookie containing the Firebase ID token after login (you write this cookie yourself, typically via a small API route or &lt;code&gt;onIdTokenChanged&lt;/code&gt; listener). The server then rebuilds an authenticated Firebase app instance from that cookie — this is meaningfully more manual than Supabase's &lt;code&gt;@supabase/ssr&lt;/code&gt; package, which handles cookie syncing for you out of the box.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This example demonstrates the server-side bridge rather than a complete production session-cookie implementation. In production, use a secure HTTP-only session cookie and validate it server-side.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Assuming Firebase Auth &quot;just works&quot; with Server Components the way it works with a client-only SPA. The App Router's server/client split means you need this extra bridging step — it's the single biggest source of Firebase + Next.js App Router complaints online.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-6-protect-data-with-firestore-security-rules&quot;&gt;Step 6: Protect Data with Firestore Security Rules&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /profiles/{userId} {
      allow read, update: if request.auth != null &amp;amp;&amp;amp; request.auth.uid == userId;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Firestore rules are enforced server-side by Google's infrastructure, similar in spirit to RLS — but written in a proprietary rules language instead of SQL, so there's a real learning curve if your team already knows SQL but not Firestore's rule syntax.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pricing-comparison&quot;&gt;Pricing Comparison&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Supabase Auth &lt;/th&gt;&lt;th&gt;Firebase Auth&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Free tier &lt;/td&gt;&lt;td&gt;50,000 MAU &lt;/td&gt;&lt;td&gt;Depends on Firebase Auth / Identity Platform configuration&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Paid tier &lt;/td&gt;&lt;td&gt;Included in Supabase plan quota; additional MAU billed after quota &lt;/td&gt;&lt;td&gt;Usage-based pricing with Identity Platform&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Self-hosting &lt;/td&gt;&lt;td&gt;✅ Available &lt;/td&gt;&lt;td&gt;❌ Not available&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Both platforms offer generous options for early-stage projects, although the exact limits and pricing depend on the plan and services you use. The real cost difference shows up later: Supabase gives you a self-hosting escape hatch if you ever want to leave the MAU pricing model entirely; Firebase does not.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;security-considerations-for-both&quot;&gt;Security Considerations for Both&lt;/h3&gt;
&lt;p&gt;Regardless of which you choose, keep these in mind for Next.js 16:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Never trust client-side auth checks alone.&lt;/strong&gt; Whether you're using Supabase or Firebase, always re-verify the session inside Server Actions and Route Handlers — a hidden button in the UI doesn't stop a direct API call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;proxy.ts&lt;/code&gt; (formerly &lt;code&gt;middleware.ts&lt;/code&gt;) is a UX nicety, not your security boundary.&lt;/strong&gt; Both Supabase's session refresh and any Firebase token check you add there can be bypassed by calling your backend directly — the real gate is the check inside your Server Action or database rule.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Default new users to the lowest-privilege role.&lt;/strong&gt; Never let a signup form set its own &lt;code&gt;role&lt;/code&gt; or &lt;code&gt;is_admin&lt;/code&gt; field — assign it server-side.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-should-you-use-supabase-auth-&quot;&gt;When Should You Use Supabase Auth?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;You want a real relational database (Postgres) under your app, not just auth.&lt;/li&gt;
&lt;li&gt;Your app has complex relationships — teams, roles, shared resources, multi-tenancy.&lt;/li&gt;
&lt;li&gt;You want the option to self-host later, or you like knowing you &lt;em&gt;could&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;You're already comfortable with SQL, or want to learn it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-should-you-use-firebase-auth-&quot;&gt;When Should You Use Firebase Auth?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;You're already using Firestore, Cloud Functions, Firebase Cloud Messaging, or Google Analytics.&lt;/li&gt;
&lt;li&gt;Your data model is simple and document-shaped (a user has a profile, a handful of settings — no deep relational joins).&lt;/li&gt;
&lt;li&gt;You want Google's massive scale and long production track record specifically for mobile + web apps together.&lt;/li&gt;
&lt;li&gt;Your team already knows Firestore Security Rules.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;decision-table&quot;&gt;Decision Table&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;If you need...              &lt;/th&gt;&lt;th&gt;Better choice&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Next.js + PostgreSQL        &lt;/td&gt;&lt;td&gt;🟢 Supabase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;SQL + relational data       &lt;/td&gt;&lt;td&gt;🟢 Supabase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;RLS-based authorization     &lt;/td&gt;&lt;td&gt;🟢 Supabase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Self-hosting                &lt;/td&gt;&lt;td&gt;🟢 Supabase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Firestore                   &lt;/td&gt;&lt;td&gt;🟢 Firebase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Firebase ecosystem          &lt;/td&gt;&lt;td&gt;🟢 Firebase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Google Cloud integration    &lt;/td&gt;&lt;td&gt;🟢 Firebase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Mobile + web Firebase stack &lt;/td&gt;&lt;td&gt;🟢 Firebase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Complex relational SaaS     &lt;/td&gt;&lt;td&gt;🟢 Supabase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Document-oriented data      &lt;/td&gt;&lt;td&gt;🟢 Firebase&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions-1&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;can-i-switch-from-firebase-auth-to-supabase-auth-later-&quot;&gt;Can I switch from Firebase Auth to Supabase Auth later?&lt;/h4&gt;
&lt;p&gt;Yes, but it's real migration work. You'll need to migrate users and identities, account for password-hash compatibility and provider configuration, and rebuild authorization rules from your Firebase/Security Rules model into Supabase RLS.&lt;/p&gt;
&lt;h4 id=&quot;which-one-is-easier-for-a-complete-beginner-&quot;&gt;Which one is easier for a complete beginner?&lt;/h4&gt;
&lt;p&gt;Firebase Auth's client-only setup (no SSR bridging) is slightly faster to get a basic login working. But Supabase Auth's &lt;code&gt;@supabase/ssr&lt;/code&gt; package makes Next.js App Router integration smoother once you add server-side checks, which almost every real app eventually needs.&lt;/p&gt;
&lt;h4 id=&quot;does-supabase-auth-support-social-login-like-google-and-github-&quot;&gt;Does Supabase Auth support social login like Google and GitHub?&lt;/h4&gt;
&lt;p&gt;Yes — Supabase Auth supports Google, GitHub, Apple, Discord, and dozens of other OAuth providers, configured directly from the Supabase dashboard with no extra backend code required.&lt;/p&gt;
&lt;h4 id=&quot;is-firebase-auth-open-source-&quot;&gt;Is Firebase Auth open source?&lt;/h4&gt;
&lt;p&gt;No. Firebase Auth is a fully managed Google Cloud service — you can't self-host it or inspect its internals. Supabase Auth (part of the broader Supabase platform, built on the open-source GoTrue project) is open source and can be self-hosted.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-row-level-security-if-i-m-already-checking-auth-in-my-next-js-code-&quot;&gt;Do I need Row Level Security if I'm already checking auth in my Next.js code?&lt;/h4&gt;
&lt;p&gt;RLS protects your database from application-level mistakes and direct client access, but it does not protect against misuse of a secret/service-role key because those elevated keys bypass RLS.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-2&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Supabase Auth and Firebase Auth both get you a working login system fast — the real decision comes down to what's underneath: a relational Postgres database with SQL-based security, or Firestore's document model with Google's rules language. If your app has real relationships between users, teams, and data, Supabase's Postgres foundation will save you pain down the road. If you're already deep in the Google Cloud ecosystem, Firebase Auth keeps everything under one roof.&lt;/p&gt;
&lt;p&gt;Whichever you pick, remember the same rule applies to both: UI checks are for user experience, and server-side/database-level checks are your actual security boundary.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning-2&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-supabase-todo-app&quot;&gt;Build a Todo App with Next.js 16 and Supabase (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/clerk-vs-nextauth-2026&quot;&gt;Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;helpful-free-tools&quot;&gt;Helpful Free Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; — generate strict, pattern-specific Row Level Security policies in seconds instead of hand-writing SQL.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/zod-schema-generator&quot;&gt;Zod Schema Generator&lt;/a&gt; — generate validated schemas for your auth forms straight from JSON or TypeScript, including a ready-to-use Server Action.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24980/supabase-auth-vs-firebase-auth-which-one-should-you-choose-in-2026</guid>
<pubDate>Thu, 20 Aug 2026 10:26:22 +0000</pubDate>
</item>
<item>
<title>🐾 Cat Guardian — Protect. Identify. Find.</title>
<link>https://coderlegion.com/24788/cat-guardian-protect-identify-find</link>
<description>&lt;p&gt;I live with seven cats: Kiara, Golia, Meias, Vaquinha, Tigrinha, Peluda, and Gamora.&lt;/p&gt;
&lt;p&gt;They all have different personalities, different appearances, and different routines.&lt;/p&gt;
&lt;p&gt;And, like many pet owners, there is one thing that has always worried me:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if one of them gets lost?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That question became the starting point for Cat Guardian.&lt;/p&gt;
&lt;h3 id=&quot;what-i-built&quot;&gt;What I Built&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Cat Guardian&lt;/strong&gt; is a privacy-first digital safety passport for cats.&lt;/p&gt;
&lt;p&gt;The idea is simple: give each cat a digital identity containing the information that could actually help if they ever go missing.&lt;/p&gt;
&lt;p&gt;The platform provides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;🐈 Digital identity for each cat&lt;/li&gt;
&lt;li&gt;🩺 Health records and vaccination tracking&lt;/li&gt;
&lt;li&gt;🧠 AI-assisted visual identification&lt;/li&gt;
&lt;li&gt;🏷️ Dynamic QR collar tags&lt;/li&gt;
&lt;li&gt;🚨 Lost Mode&lt;/li&gt;
&lt;li&gt;📱 Public rescue passport&lt;/li&gt;
&lt;li&gt;🔐 Privacy-preserving contact between finder and owner&lt;/li&gt;
&lt;li&gt;🌎 English and Brazilian Portuguese localization&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-problem&quot;&gt;The problem&lt;/h4&gt;
&lt;p&gt;When a pet goes missing, useful information is often scattered across photos, veterinary records, paper documents, and the owner's memory.&lt;/p&gt;
&lt;p&gt;Traditional collar tags can also expose personal information such as phone numbers or addresses.&lt;/p&gt;
&lt;p&gt;I wanted a different approach.&lt;/p&gt;
&lt;p&gt;The QR code attached to the collar doesn't expose the owner's contact information.&lt;/p&gt;
&lt;p&gt;Instead, when someone finds the cat, they can scan the QR code and access a public rescue profile.&lt;/p&gt;
&lt;p&gt;If the owner has activated &lt;strong&gt;Lost Mode&lt;/strong&gt;, the finder can submit a sighting.&lt;/p&gt;
&lt;p&gt;The owner is then notified through a secure backend relay.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Owner
  │
  ▼
Cat Guardian
  │
  ├── Digital Identity
  ├── Health Records
  ├── AI Profile
  └── QR Collar Tag
          │
          ▼
      Lost Mode
          │
          ▼
     Finder scans QR
          │
          ▼
    Public Rescue Card
          │
          ▼
     Submit Sighting
          │
          ▼
       Owner notified
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The goal isn't to replace GPS trackers or microchips.&lt;/p&gt;
&lt;p&gt;It is to make the information surrounding a lost pet &lt;strong&gt;available, structured, and safer to share&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;why-ai-&quot;&gt;Why AI?&lt;/h4&gt;
&lt;p&gt;I didn't want to add AI simply because this was an AI-related challenge.&lt;/p&gt;
&lt;p&gt;I wanted to use it where it could provide practical value.&lt;/p&gt;
&lt;p&gt;Gemini is used to analyze visual characteristics such as coat patterns, eye color, and distinctive markings and generate a structured identification profile.&lt;/p&gt;
&lt;p&gt;The application also provides informational preventive wellness guidance, while explicitly keeping medical diagnosis and treatment outside its scope.&lt;/p&gt;
&lt;p&gt;AI-generated profiles are also localized according to the user's language.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;demo&quot;&gt;Demo&lt;/h3&gt;
&lt;p&gt;🌐 &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cat-guardian-two.vercel.app/&quot;&gt;Open the Live Demo →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The application includes a &lt;strong&gt;Demo Mode&lt;/strong&gt;, so you can explore the main experience without creating an account.&lt;/p&gt;
&lt;p&gt;The demo contains my seven cats:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kiara · Golia · Meias · Vaquinha · Tigrinha · Peluda · Gamora&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Try the Lost &amp;amp; Found flow with the demo data and explore how a finder would interact with a cat's public rescue passport.&lt;/p&gt;
&lt;h4 id=&quot;key-flows&quot;&gt;Key flows&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;1. Register a cat&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Create a digital identity and add health information.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Generate a QR collar tag&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The QR code points to the cat's public safety passport.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Activate Lost Mode&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The owner can mark the cat as missing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Finder scans the QR code&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The finder sees the relevant rescue information without seeing the owner's private contact details.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Submit a sighting&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The finder can send information about where and when the cat was seen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Owner receives the notification&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The backend relays the sighting to the owner through Resend.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;code&quot;&gt;Code&lt;/h3&gt;
&lt;p&gt;💻 &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/lfrichter/cat-guardian&quot;&gt;GitHub Repository →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The project is open source and built with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;React 19&lt;/li&gt;
&lt;li&gt;Vite 6&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Supabase PostgreSQL&lt;/li&gt;
&lt;li&gt;Supabase Auth&lt;/li&gt;
&lt;li&gt;Row Level Security&lt;/li&gt;
&lt;li&gt;Gemini&lt;/li&gt;
&lt;li&gt;Ollama&lt;/li&gt;
&lt;li&gt;Mistral 7B&lt;/li&gt;
&lt;li&gt;OpenCoder 8B&lt;/li&gt;
&lt;li&gt;Resend&lt;/li&gt;
&lt;li&gt;i18next / react-i18next&lt;/li&gt;
&lt;li&gt;Vitest&lt;/li&gt;
&lt;li&gt;React Testing Library&lt;/li&gt;
&lt;li&gt;ESLint&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-i-built-it&quot;&gt;How I Built It&lt;/h3&gt;
&lt;p&gt;This project was also an experiment in &lt;strong&gt;AI-assisted software engineering&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I used my own &lt;strong&gt;AI Software Engineering Framework 2.0&lt;/strong&gt; to orchestrate the development process.&lt;/p&gt;
&lt;p&gt;The goal was not simply to ask an LLM to generate the application.&lt;/p&gt;
&lt;p&gt;Instead, I treated AI agents as specialized members of an engineering workflow.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Human Supervisor
       │
       ▼
Chief of Staff
       │
       ▼
   Risk Router
    /   |   \
   ▼    ▼    ▼
Mistral OpenCoder Gemini
   \    |    /
    \   |   /
      Jidoka
         │
         ▼
   Human Approval
         │
         ▼
       Merge
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;risk-based-agent-routing&quot;&gt;Risk-based agent routing&lt;/h4&gt;
&lt;p&gt;Simple and low-risk tasks were delegated to local models running through Ollama.&lt;/p&gt;
&lt;p&gt;🟢 &lt;strong&gt;Mistral 7B&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Used for tasks such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;documentation&lt;/li&gt;
&lt;li&gt;simple UI changes&lt;/li&gt;
&lt;li&gt;repetitive work&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;🟡 &lt;strong&gt;OpenCoder 8B&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Used for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CRUD&lt;/li&gt;
&lt;li&gt;forms&lt;/li&gt;
&lt;li&gt;implementation tasks&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;🔴 &lt;strong&gt;Gemini&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Used for higher-risk tasks such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;authentication&lt;/li&gt;
&lt;li&gt;Row Level Security&lt;/li&gt;
&lt;li&gt;AI integrations&lt;/li&gt;
&lt;li&gt;architectural decisions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This allowed me to keep the more capable model focused on tasks where it provided the most value.&lt;/p&gt;
&lt;h4 id=&quot;jidoka-quality-gate&quot;&gt;Jidoka Quality Gate&lt;/h4&gt;
&lt;p&gt;Every implementation had to pass a validation pipeline:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Lint
  ↓
Typecheck
  ↓
Tests
  ↓
Build
  ↓
Human Approval
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a validation step failed, the workflow stopped instead of allowing the problem to propagate.&lt;/p&gt;
&lt;p&gt;This follows the &lt;strong&gt;Jidoka / Andon Cord&lt;/strong&gt; principle from my framework:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;When an anomaly is detected, stop the process and fix it before continuing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The final release candidate passed:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Gate             &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;ESLint           &lt;/td&gt;&lt;td&gt;✅ PASS&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TypeScript       &lt;/td&gt;&lt;td&gt;✅ PASS&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Vitest           &lt;/td&gt;&lt;td&gt;✅ 21/21 PASS&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Production Build &lt;/td&gt;&lt;td&gt;✅ PASS&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h4 id=&quot;privacy-first-architecture&quot;&gt;Privacy-first architecture&lt;/h4&gt;
&lt;p&gt;The public rescue passport deliberately does not expose:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;owner's email&lt;/li&gt;
&lt;li&gt;phone number&lt;/li&gt;
&lt;li&gt;physical address&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The finder communicates through a backend relay.&lt;/p&gt;
&lt;p&gt;Sightings are persisted in PostgreSQL and the owner is notified through the Resend API.&lt;/p&gt;
&lt;p&gt;This creates a simple separation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Finder
  │
  ▼
Public Rescue Passport
  │
  ▼
Sighting
  │
  ▼
Backend
  ├── PostgreSQL
  └── Resend
          │
          ▼
        Owner
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The finder can help without gaining direct access to the owner's private contact information.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;internationalization&quot;&gt;Internationalization&lt;/h4&gt;
&lt;p&gt;English is the default language, with Brazilian Portuguese as a secondary locale.&lt;/p&gt;
&lt;p&gt;I also wanted AI-generated information to respect localization.&lt;/p&gt;
&lt;p&gt;The localized AI profiles are stored in PostgreSQL using JSONB:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;en&quot;: {
    &quot;description&quot;: &quot;...&quot;,
    &quot;personality&quot;: &quot;...&quot;
  },
  &quot;pt-BR&quot;: {
    &quot;description&quot;: &quot;...&quot;,
    &quot;personality&quot;: &quot;...&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This keeps the cat's identity data together while allowing the presentation to adapt to the user's language.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;design-system&quot;&gt;Design System&lt;/h4&gt;
&lt;p&gt;The application uses a custom &lt;strong&gt;Midnight Guardian&lt;/strong&gt; design system.&lt;/p&gt;
&lt;p&gt;The visual language combines:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Midnight Navy&lt;/li&gt;
&lt;li&gt;Lavender&lt;/li&gt;
&lt;li&gt;Emerald&lt;/li&gt;
&lt;li&gt;Amber&lt;/li&gt;
&lt;li&gt;Coral&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The goal was to make the application feel like a safety product rather than a generic pet CRUD application.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;-privacy-amp-safety&quot;&gt;🔐 Privacy &amp;amp; Safety&lt;/h4&gt;
&lt;p&gt;Privacy was treated as a product requirement rather than an afterthought.&lt;/p&gt;
&lt;p&gt;The public QR page is intentionally limited to information that can help identify and recover the cat.&lt;/p&gt;
&lt;p&gt;Owner PII is not rendered publicly.&lt;/p&gt;
&lt;p&gt;The system also does not attempt to provide veterinary diagnosis.&lt;/p&gt;
&lt;p&gt;Cat Guardian provides informational preventive wellness guidance and does not replace professional veterinary advice.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;-current-limitations&quot;&gt;🚧 Current Limitations&lt;/h4&gt;
&lt;p&gt;Cat Guardian is a hackathon prototype.&lt;/p&gt;
&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; currently provide:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;real-time GPS tracking;&lt;/li&gt;
&lt;li&gt;GPS collar hardware integration;&lt;/li&gt;
&lt;li&gt;veterinary diagnosis;&lt;/li&gt;
&lt;li&gt;guaranteed pet recovery.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These could be explored in future versions.&lt;/p&gt;
&lt;p&gt;For this challenge, I intentionally focused on the digital identity, QR-based rescue flow, privacy-preserving communication, and AI-assisted identification.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;-what-i-learned&quot;&gt;🎯 What I Learned&lt;/h4&gt;
&lt;p&gt;The biggest challenge wasn't building the features.&lt;/p&gt;
&lt;p&gt;It was deciding &lt;strong&gt;what not to build&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;It would have been easy to add GPS, maps, mobile applications, IoT devices, blockchain, push notifications, and many other features.&lt;/p&gt;
&lt;p&gt;But the core idea was much simpler:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Give a cat a digital identity that can help protect, identify, and reunite them with their owner.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Everything else had to support that idea.&lt;/p&gt;
&lt;p&gt;And the project became much stronger because of that constraint.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;-built-for-seven-cats&quot;&gt;🐾 Built for seven cats&lt;/h4&gt;
&lt;p&gt;Cat Guardian started with a very personal question:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What information would I want to have if one of my cats disappeared tomorrow?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I would want their photo.&lt;/p&gt;
&lt;p&gt;Their identifying characteristics.&lt;/p&gt;
&lt;p&gt;Their health information.&lt;/p&gt;
&lt;p&gt;Their vaccination history.&lt;/p&gt;
&lt;p&gt;Their microchip information.&lt;/p&gt;
&lt;p&gt;And, most importantly:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I would want the person who found them to have a safe way to contact me.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That's Cat Guardian.&lt;/p&gt;
&lt;p&gt;Built for:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kiara · Golia · Meias · Vaquinha · Tigrinha · Peluda · Gamora&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;🐾&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prize-categories&quot;&gt;Prize Categories&lt;/h3&gt;
&lt;p&gt;I'm submitting Cat Guardian for:&lt;/p&gt;
&lt;h4 id=&quot;-best-use-of-google-ai&quot;&gt;🏆 Best Use of Google AI&lt;/h4&gt;
&lt;p&gt;Gemini is used meaningfully in the application for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AI-assisted visual identification profiles&lt;/li&gt;
&lt;li&gt;feline characteristic analysis&lt;/li&gt;
&lt;li&gt;preventive wellness assistance&lt;/li&gt;
&lt;li&gt;localized AI-generated content&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Google AI is also used as the high-risk agent in the AI-assisted engineering workflow.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-links&quot;&gt;🔗 Links&lt;/h3&gt;
&lt;p&gt;🌐 &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cat-guardian-two.vercel.app/&quot;&gt;Live Demo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;💻 &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/lfrichter/cat-guardian&quot;&gt;GitHub Repository&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24788/cat-guardian-protect-identify-find</guid>
<pubDate>Mon, 17 Aug 2026 15:12:42 +0000</pubDate>
</item>
<item>
<title>Build a Todo App with Next.js 16 and Supabase (2026 Guide)</title>
<link>https://coderlegion.com/23100/build-a-todo-app-with-next-js-16-and-supabase-2026-guide</link>
<description>&lt;p&gt;In this guide, we’ll build a production-ready Todo App using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Next.js 16&lt;/li&gt;
&lt;li&gt;Supabase&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;li&gt;Server Actions&lt;/li&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;li&gt;Row Level Security (RLS)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This tutorial focuses more on &lt;strong&gt;logic and architecture&lt;/strong&gt; rather than UI design.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Authentication with Supabase&lt;/li&gt;
&lt;li&gt;Protected routes&lt;/li&gt;
&lt;li&gt;Todo CRUD operations&lt;/li&gt;
&lt;li&gt;Row Level Security (RLS)&lt;/li&gt;
&lt;li&gt;Form validation with Zod&lt;/li&gt;
&lt;li&gt;Server Actions&lt;/li&gt;
&lt;li&gt;Secure session handling&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-we-re-building-1&quot;&gt;What We’re Building&lt;/h3&gt;
&lt;p&gt;Our Todo App will include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User Registration&lt;/li&gt;
&lt;li&gt;User Login&lt;/li&gt;
&lt;li&gt;Protected Dashboard&lt;/li&gt;
&lt;li&gt;Create Todo&lt;/li&gt;
&lt;li&gt;Complete Todo&lt;/li&gt;
&lt;li&gt;Delete Todo&lt;/li&gt;
&lt;li&gt;Secure Database Access with RLS&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites-1&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Node.js installed&lt;/li&gt;
&lt;li&gt;A Supabase account&lt;/li&gt;
&lt;li&gt;Basic understanding of React and Next.js&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;folder-structure&quot;&gt;Folder Structure&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;proxy.ts
src
├── actions
│   ├── auth.ts
│   └── todo.ts
├── app
│   ├── layout.tsx
│   ├── (auth)
│   │   ├── layout.tsx
│   │   ├── login
│   │   │   └── page.tsx
│   │   ├── register
│   │   │   └── page.tsx
│   ├── (private)
│   |   ├── layout.tsx
│   |   ├── page.tsx
│   |   ├── create
│   │   │   └── page.tsx
├── components
│   ├── form-error.tsx
│   ├── input.tsx
│   ├── loading-button.tsx
│   ├── navbar.tsx
│   └── todo-actions.tsx
├── utils
│   ├── supabase
│   |   ├── client.ts
│   |   ├── proxy.ts
│   |   └── server.ts
│   ├── validators
│   |   ├── auth.ts
│   │   └── todo.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-a-next-js-16-project&quot;&gt;Create a Next.js 16 Project&lt;/h3&gt;
&lt;p&gt;Run the following command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx create-next-app@latest todo-app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Choose options according to your preferences.&lt;/p&gt;
&lt;p&gt;Recommended setup:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;li&gt;App Router&lt;/li&gt;
&lt;li&gt;React Compiler&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/&lt;/code&gt; directory&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now move into the project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd todo-app
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;setup-supabase&quot;&gt;Setup Supabase&lt;/h3&gt;
&lt;p&gt;Go to &lt;a rel=&quot;nofollow&quot; href=&quot;https://supabase.com/dashboard/&quot;&gt;Supabase Dashboard&lt;/a&gt;:&lt;/p&gt;
&lt;h4 id=&quot;create-a-new-project&quot;&gt;Create a New Project&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;New Project&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Enter project name&lt;/li&gt;
&lt;li&gt;Enter database password&lt;/li&gt;
&lt;li&gt;Select region&lt;/li&gt;
&lt;li&gt;Enable automatic RLS&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create Project&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;add-environment-variables&quot;&gt;Add Environment Variables&lt;/h3&gt;
&lt;p&gt;Inside your project Dashboard:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Under &lt;strong&gt;Get Connected&lt;/strong&gt; section&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Frameworks&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Copy the environment variables.&lt;/p&gt;
&lt;p&gt;Create &lt;code&gt;.env.local&lt;/code&gt; in your project root:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;install-supabase-packages&quot;&gt;Install Supabase Packages&lt;/h3&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @supabase/supabase-js @supabase/ssr
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We’ll also install Zod later for validation.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;understanding-supabase-clients&quot;&gt;Understanding Supabase Clients&lt;/h3&gt;
&lt;p&gt;Supabase provides two client types:&lt;/p&gt;
&lt;h4 id=&quot;browser-client&quot;&gt;Browser Client&lt;/h4&gt;
&lt;p&gt;Used inside Client Components.&lt;/p&gt;
&lt;h4 id=&quot;server-client&quot;&gt;Server Client&lt;/h4&gt;
&lt;p&gt;Used inside:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Server Components&lt;/li&gt;
&lt;li&gt;Server Actions&lt;/li&gt;
&lt;li&gt;Route Handlers&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-supabase-browser-client&quot;&gt;Create Supabase Browser Client&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/utils/supabase/client.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { createBrowserClient } from &quot;@supabase/ssr&quot;;

export const createClient = () =&amp;gt;
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-supabase-server-client&quot;&gt;Create Supabase Server Client&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/utils/supabase/server.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { createServerClient } from &quot;@supabase/ssr&quot;;
import { cookies } from &quot;next/headers&quot;;

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },

        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =&amp;gt;
              cookieStore.set(name, value, options)
            );
          } catch {
            // Ignore if called from Server Component
          }
        },
      },
    }
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;setup-supabase-session-middleware&quot;&gt;Setup Supabase Session Middleware&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/utils/supabase/proxy.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { createServerClient } from &quot;@supabase/ssr&quot;;
import { NextResponse, type NextRequest } from &quot;next/server&quot;;

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },

        setAll(cookiesToSet, headers) {
          cookiesToSet.forEach(({ name, value }) =&amp;gt;
            request.cookies.set(name, value)
          );

          supabaseResponse = NextResponse.next({
            request,
          });

          cookiesToSet.forEach(({ name, value, options }) =&amp;gt;
            supabaseResponse.cookies.set(name, value, options)
          );

          Object.entries(headers).forEach(([key, value]) =&amp;gt;
            supabaseResponse.headers.set(key, value)
          );
        },
      },
    }
  );

  await supabase.auth.getClaims();

  return supabaseResponse;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;setup-next-js-middleware&quot;&gt;Setup Next.js Middleware&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/proxy.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { type NextRequest } from &quot;next/server&quot;;
import { updateSession } from &quot;@/utils/supabase/proxy&quot;;

export async function proxy(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * Feel free to modify this pattern to include more paths.
    */
    &quot;/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)&quot;,
  ],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-route-groups&quot;&gt;Create Route Groups&lt;/h3&gt;
&lt;p&gt;We’ll use route groups for better organization.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;app
 ├── (auth)
 └── (private)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Route groups help organize routes without affecting URLs.&lt;br&gt;
Learn more about &lt;a rel=&quot;nofollow&quot; href=&quot;https://nextjs.org/docs/app/api-reference/file-conventions/route-groups&quot;&gt;Nextjs Route Groups&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;create-auth-layout&quot;&gt;Create Auth Layout&lt;/h4&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/app/(auth)/layout.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export default function AuthLayout({
  children,
}: Readonly&amp;lt;{
  children: React.ReactNode;
}&amp;gt;) {
  return (
    &amp;lt;div className=&quot;bg-gray-900 flex items-center justify-center flex-1&quot;&amp;gt;
      {children}
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-login-page&quot;&gt;Create Login Page&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/app/(auth)/login/page.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&quot;use client&quot;;

import { login } from &quot;@/actions/auth&quot;;
import FormError from &quot;@/components/form-error&quot;;
import Input from &quot;@/components/input&quot;;
import LoadingButton from &quot;@/components/loading-button&quot;;
import Image from &quot;next/image&quot;;
import Link from &quot;next/link&quot;;
import { useActionState } from &quot;react&quot;;

export default function Login() {
  const [state, formAction] = useActionState(login, {
    errors: {
      email: [],
      password: [],
      formError: &quot;&quot;,
    },
    data: {
      email: &quot;&quot;,
      password: &quot;&quot;,
    },
  });

  return (
    &amp;lt;div className=&quot;flex flex-col justify-center w-full&quot;&amp;gt;
      &amp;lt;div className=&quot;sm:mx-auto sm:w-full sm:max-w-sm&quot;&amp;gt;
        &amp;lt;Image
          src=&quot;https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&amp;amp;shade=500&quot;
          alt=&quot;Logo&quot;
          className=&quot;mx-auto&quot;
          width={50}
          height={50}
        /&amp;gt;

        &amp;lt;h2 className=&quot;mt-10 text-center text-2xl/9 font-bold tracking-tight text-white&quot;&amp;gt;
          Sign in to your account
        &amp;lt;/h2&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div className=&quot;mt-10 sm:mx-auto sm:w-full sm:max-w-sm&quot;&amp;gt;
        &amp;lt;form action={formAction} className=&quot;space-y-6&quot;&amp;gt;
          &amp;lt;Input
            defaultValue={state?.data?.email || &quot;&quot;}
            name=&quot;email&quot;
            type=&quot;email&quot;
            label=&quot;Email address&quot;
          /&amp;gt;

          &amp;lt;FormError errors={state.errors.email} /&amp;gt;

          &amp;lt;Input
            defaultValue={state?.data?.password || &quot;&quot;}
            name=&quot;password&quot;
            type=&quot;password&quot;
            label=&quot;Password&quot;
          /&amp;gt;

          &amp;lt;FormError errors={state.errors.password} /&amp;gt;

          &amp;lt;LoadingButton title=&quot;Sign in&quot; /&amp;gt;

          &amp;lt;FormError errors={state?.errors?.formError} /&amp;gt;
        &amp;lt;/form&amp;gt;

        &amp;lt;p className=&quot;mt-10 text-center text-sm/6 text-gray-400&quot;&amp;gt;
          Not a member?{&quot; &quot;}
          &amp;lt;Link
            href=&quot;/register&quot;
            className=&quot;font-semibold text-indigo-400 hover:text-indigo-300&quot;
          &amp;gt;
            Register
          &amp;lt;/Link&amp;gt;
        &amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;important-note-about-input-names&quot;&gt;Important Note About Input Names&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;name&lt;/code&gt; attribute is extremely important because we later access form values using:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;formData.get(&quot;email&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If names don’t match, your form won’t work correctly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-reusable-input-component&quot;&gt;Create Reusable Input Component&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/components/input.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;interface InputProps {
  name: string;
  label?: string;
  type?: string;
  defaultValue?: string;
  required?: boolean;
}

const Input = ({
  name,
  label,
  type = &quot;text&quot;,
  defaultValue,
  required = true,
}: InputProps) =&amp;gt; {
  return (
    &amp;lt;div&amp;gt;
      {label &amp;amp;&amp;amp; (
        &amp;lt;label
          htmlFor={name}
          className=&quot;block text-sm/6 font-medium text-gray-100&quot;
        &amp;gt;
          {label}
        &amp;lt;/label&amp;gt;
      )}

      &amp;lt;div className=&quot;mt-2&quot;&amp;gt;
        &amp;lt;input
          id={name}
          type={type}
          name={name}
          defaultValue={defaultValue}
          required={required}
          autoComplete={type === &quot;email&quot; ? &quot;email&quot; : undefined}
          className=&quot;block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 outline-white/10&quot;
        /&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};

export default Input;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-loading-button&quot;&gt;Create Loading Button&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/components/loading-button.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&quot;use client&quot;;

import { useFormStatus } from &quot;react-dom&quot;;

const LoadingButton = ({
  title,
  className = &quot;&quot;,
}: {
  title: string;
  className?: string;
}) =&amp;gt; {
  const { pending } = useFormStatus();

  return (
    &amp;lt;button
      disabled={pending}
      type=&quot;submit&quot;
      className={`cursor-pointer flex w-full justify-center rounded-md bg-indigo-500 px-3 py-1.5 text-white ${className}`}
    &amp;gt;
      {pending ? &quot;Loading...&quot; : title}
    &amp;lt;/button&amp;gt;
  );
};

export default LoadingButton;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-useformstatus-&quot;&gt;What is useFormStatus?&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;useFormStatus()&lt;/code&gt; gives information about the latest form submission state.&lt;/p&gt;
&lt;p&gt;We use it to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Disable button while submitting&lt;/li&gt;
&lt;li&gt;Show loading state&lt;/li&gt;
&lt;li&gt;Prevent duplicate submissions&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-form-error-component&quot;&gt;Create Form Error Component&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/components/form-error.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export default function FormError({
  errors,
}: {
  errors?: string | string[];
}) {
  if (!errors || (Array.isArray(errors) &amp;amp;&amp;amp; errors.length === 0)) {
    return null;
  }

  return (
    &amp;lt;p aria-live=&quot;polite&quot; className=&quot;text-sm text-red-500&quot;&amp;gt;
      {typeof errors === &quot;string&quot; ? errors : errors.join(&quot;, &quot;)}
    &amp;lt;/p&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;install-zod&quot;&gt;Install Zod&lt;/h3&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install zod
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-auth-validation-schema&quot;&gt;Create Auth Validation Schema&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/utils/validators/auth.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import z from &quot;zod&quot;;

export const authSchema = z.object({
  email: z.email(),
  password: z
    .string()
    .min(6, &quot;Password must be at least 6 characters long&quot;)
    .max(100, &quot;Password must not exceed 100 characters&quot;),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-login-action&quot;&gt;Create Login Action&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/actions/auth.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;&quot;use server&quot;;

import { redirect } from &quot;next/navigation&quot;;
import { createClient } from &quot;@/utils/supabase/server&quot;;
import { authSchema } from &quot;@/utils/validators/auth&quot;;

export async function login(prev: unknown, formData: FormData) {
  const data = {
    email: formData.get(&quot;email&quot;) as string,
    password: formData.get(&quot;password&quot;) as string,
  };

  const validatedFields = authSchema.safeParse(data);

  if (!validatedFields.success) {
    return {
      data,
      errors: {
        ...validatedFields.error.flatten().fieldErrors,
        formError: &quot;&quot;,
      },
    };
  }

  const supabase = await createClient();

  const { error } = await supabase.auth.signInWithPassword(
    validatedFields.data
  );

  if (error) {
    return {
      data,
      errors: {
        formError: error.message,
        email: [],
        password: [],
      },
    };
  }

  redirect(&quot;/&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;understanding-the-login-action&quot;&gt;Understanding the Login Action&lt;/h3&gt;
&lt;p&gt;Here’s what happens:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Get form values using &lt;code&gt;formData&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Validate using Zod&lt;/li&gt;
&lt;li&gt;Call Supabase Auth API&lt;/li&gt;
&lt;li&gt;Redirect user after successful login&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;Because this is a &lt;strong&gt;Server Action&lt;/strong&gt;, we must use the server Supabase client.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-register-page&quot;&gt;Create Register Page&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/app/(auth)/register/page.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use the same structure as login page but replace:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { signup } from &quot;@/actions/auth&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;LoadingButton title=&quot;Sign up&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-signup-action&quot;&gt;Create Signup Action&lt;/h3&gt;
&lt;p&gt;Add this inside:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/actions/auth.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export async function signup(prev: unknown, formData: FormData) {
  const data = {
    email: formData.get(&quot;email&quot;) as string,
    password: formData.get(&quot;password&quot;) as string,
  };

  const validatedFields = authSchema.safeParse(data);

  if (!validatedFields.success) {
    return {
      data,
      errors: {
        ...validatedFields.error.flatten().fieldErrors,
        formError: &quot;&quot;,
      },
    };
  }

  const supabase = await createClient();

  const { error } = await supabase.auth.signUp(validatedFields.data);

  if (error) {
    return {
      data,
      errors: {
        formError: error.message,
        email: [],
        password: [],
      },
    };
  }

  redirect(&quot;/&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;Supabase may require email confirmation depending on project settings after signup.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-private-layout&quot;&gt;Create Private Layout&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/app/(private)/layout.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import Navbar from &quot;@/components/navbar&quot;;

export default function PrivateLayout({
  children,
}: Readonly&amp;lt;{
  children: React.ReactNode;
}&amp;gt;) {
  return (
    &amp;lt;&amp;gt;
      &amp;lt;Navbar /&amp;gt;

      &amp;lt;div className=&quot;mt-16&quot;&amp;gt;{children}&amp;lt;/div&amp;gt;
    &amp;lt;/&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-navbar&quot;&gt;Create Navbar&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/components/navbar.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { logout } from &quot;@/actions/auth&quot;;
import { createClient } from &quot;@/utils/supabase/server&quot;;
import LoadingButton from &quot;./loading-button&quot;;
import Link from &quot;next/link&quot;;

const Navbar = async () =&amp;gt; {
  const supabase = await createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();

  return (
    &amp;lt;div className=&quot;w-full flex items-center gap-4 p-4 bg-gray-800 justify-between&quot;&amp;gt;
      &amp;lt;div className=&quot;text-lg font-bold&quot;&amp;gt;
        Hi {user?.email}
      &amp;lt;/div&amp;gt;

      &amp;lt;div className=&quot;flex items-center gap-4&quot;&amp;gt;
        &amp;lt;nav className=&quot;flex items-center gap-4&quot;&amp;gt;
          &amp;lt;Link href=&quot;/&quot;&amp;gt;Home&amp;lt;/Link&amp;gt;
          &amp;lt;Link href=&quot;/create&quot;&amp;gt;Create Todo&amp;lt;/Link&amp;gt;
        &amp;lt;/nav&amp;gt;

        &amp;lt;form action={logout}&amp;gt;
          &amp;lt;LoadingButton title=&quot;Logout&quot; /&amp;gt;
        &amp;lt;/form&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};

export default Navbar;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-logout-action&quot;&gt;Create Logout Action&lt;/h3&gt;
&lt;p&gt;Add this to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/actions/auth.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export async function logout() {
  const supabase = await createClient();

  const { data: claimsData } = await supabase.auth.getClaims();

  if (claimsData?.claims) {
    await supabase.auth.signOut();
  }

  redirect(&quot;/login&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;protect-private-routes&quot;&gt;Protect Private Routes&lt;/h3&gt;
&lt;p&gt;Right now, users can manually access protected pages.&lt;/p&gt;
&lt;p&gt;We need route protection.&lt;/p&gt;
&lt;p&gt;Update:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/utils/supabase/proxy.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace this &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;await supabase.auth.getClaims()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { data } = await supabase.auth.getClaims();

const user = data?.claims;
const publicRoutes = [&quot;/login&quot;, &quot;/register&quot;];

if (user &amp;amp;&amp;amp; publicRoutes.includes(request.nextUrl.pathname)) {
  const url = request.nextUrl.clone();
  url.pathname = &quot;/&quot;;
  return NextResponse.redirect(url);
}

if (!user &amp;amp;&amp;amp; !publicRoutes.includes(request.nextUrl.pathname)) {
  const url = request.nextUrl.clone();
  url.pathname = &quot;/login&quot;;
  return NextResponse.redirect(url);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;understanding-logic&quot;&gt;Understanding Logic&lt;/h4&gt;
&lt;p&gt;Here’s what happens:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Get User&lt;/li&gt;
&lt;li&gt;Create Public Routes&lt;/li&gt;
&lt;li&gt;First &lt;code&gt;if&lt;/code&gt; block checks if the user is logged in &amp;amp; trying to access public routes then navigates user to Home&lt;/li&gt;
&lt;li&gt;Second &lt;code&gt;if&lt;/code&gt; block checks if the user is not logged in &amp;amp; trying to access private routes then navigates user to Login&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-route-protection-works&quot;&gt;How Route Protection Works&lt;/h3&gt;
&lt;h4 id=&quot;public-routes&quot;&gt;Public Routes&lt;/h4&gt;
&lt;p&gt;Anyone can access:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/login&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/register&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;private-routes&quot;&gt;Private Routes&lt;/h4&gt;
&lt;p&gt;Only authenticated users can access them.&lt;/p&gt;
&lt;h4 id=&quot;additional-protection&quot;&gt;Additional Protection&lt;/h4&gt;
&lt;p&gt;Logged-in users cannot revisit auth pages.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-todos-table&quot;&gt;Create Todos Table&lt;/h3&gt;
&lt;p&gt;Open:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Supabase Project Dashboard&lt;/li&gt;
&lt;li&gt;From sidebar click SQL Editor&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create table todos (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  title text not null,
  completed boolean default false,
  created_at timestamptz default now()
);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;add-row-level-security-policies&quot;&gt;Add Row Level Security Policies&lt;/h3&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create policy &quot;Users can view own todos&quot;
on todos
for select
using (auth.uid() = user_id);

create policy &quot;Users can insert own todos&quot;
on todos
for insert
with check (auth.uid() = user_id);

create policy &quot;Users can update own todos&quot;
on todos
for update
using (auth.uid() = user_id);

create policy &quot;Users can delete own todos&quot;
on todos
for delete
using (auth.uid() = user_id);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-rls-is-important&quot;&gt;Why RLS is Important&lt;/h3&gt;
&lt;p&gt;Without RLS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Any user could access all data.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With RLS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Users can only access their own data.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is one of the most important security features in Supabase.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-todo-validation-schema&quot;&gt;Create Todo Validation Schema&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/utils/validators/todo.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import z from &quot;zod&quot;;

export const todoSchema = z.object({
  title: z
    .string()
    .min(2, &quot;Title must be at least 2 characters long&quot;)
    .max(100, &quot;Title must not exceed 100 characters&quot;),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-todo-form&quot;&gt;Create Todo Form&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/app/(private)/create/page.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&quot;use client&quot;;

import { createTodo } from &quot;@/actions/todo&quot;;
import FormError from &quot;@/components/form-error&quot;;
import Input from &quot;@/components/input&quot;;
import LoadingButton from &quot;@/components/loading-button&quot;;
import { useActionState } from &quot;react&quot;;

const CreateTodo = () =&amp;gt; {
  const [state, formAction] = useActionState(createTodo, {
    error: &quot;&quot;,
    data: {
      title: &quot;&quot;,
    },
  });

  return (
    &amp;lt;div className=&quot;mt-10 sm:mx-auto sm:w-full sm:max-w-sm&quot;&amp;gt;
      &amp;lt;form action={formAction} className=&quot;space-y-6&quot;&amp;gt;
        &amp;lt;Input
          defaultValue={state?.data?.title || &quot;&quot;}
          name=&quot;title&quot;
          label=&quot;Todo Title&quot;
        /&amp;gt;

        &amp;lt;FormError errors={state?.error} /&amp;gt;

        &amp;lt;LoadingButton title=&quot;Create Todo&quot; /&amp;gt;
      &amp;lt;/form&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};

export default CreateTodo;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-todo-action&quot;&gt;Create Todo Action&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/actions/todo.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;&quot;use server&quot;;

import { todoSchema } from &quot;@/utils/validators/todo&quot;;
import { createClient } from &quot;@/utils/supabase/server&quot;;
import { redirect } from &quot;next/navigation&quot;;

export async function createTodo(
  prev: unknown,
  formData: FormData
) {
  const supabase = await createClient();

  const data = {
    title: formData.get(&quot;title&quot;) as string,
  };

  const validatedFields = todoSchema.safeParse(data);

  if (!validatedFields.success) {
    return {
      data,
      error:
        validatedFields.error.flatten().fieldErrors.title?.[0],
    };
  }

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return {
      data,
      error: &quot;Please login first&quot;,
    };
  }

  const { error } = await supabase.from(&quot;todos&quot;).insert({
    title: validatedFields.data.title,
    user_id: user.id,
  });

  if (error) {
    return {
      data,
      error: error.message,
    };
  }

  redirect(&quot;/&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;understanding-the-create-todo-action&quot;&gt;Understanding the Create Todo Action&lt;/h3&gt;
&lt;p&gt;Here’s what happens:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Get form values using &lt;code&gt;formData&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Validate using Zod&lt;/li&gt;
&lt;li&gt;Get logged in user&lt;/li&gt;
&lt;li&gt;Call Supabase Auth API&lt;/li&gt;
&lt;li&gt;Redirect user after successful insert&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-home-page&quot;&gt;Create Home Page&lt;/h3&gt;
&lt;p&gt;Move:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/app/page.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/app/(private)/page.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now update it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import TodoActions from &quot;@/components/todo-actions&quot;;
import { createClient } from &quot;@/utils/supabase/server&quot;;

export default async function Home() {
  const supabase = await createClient();

  const { data } = await supabase
    .from(&quot;todos&quot;)
    .select(&quot;*&quot;)
    .order(&quot;created_at&quot;, {
      ascending: false,
    });

  return (
    &amp;lt;div className=&quot;overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm max-w-xl mx-auto&quot;&amp;gt;
      &amp;lt;table className=&quot;divide-y divide-gray-200 w-full&quot;&amp;gt;
        &amp;lt;thead className=&quot;bg-gray-50&quot;&amp;gt;
          &amp;lt;tr&amp;gt;
            {['Todo', 'Status', 'Actions'].map((header) =&amp;gt; (
              &amp;lt;th
                key={header}
                className=&quot;px-6 py-4 text-left text-sm font-semibold text-gray-700&quot;&amp;gt;
                {header}
              &amp;lt;/th&amp;gt;
            ))}
          &amp;lt;/tr&amp;gt;
        &amp;lt;/thead&amp;gt;

        &amp;lt;tbody className=&quot;divide-y divide-gray-100 bg-white&quot;&amp;gt;
          {data?.map((todo) =&amp;gt; (
            &amp;lt;tr
              key={todo.id}
              className=&quot;transition-colors hover:bg-gray-50&quot;
            &amp;gt;
              &amp;lt;td className=&quot;px-6 py-4&quot;&amp;gt;
                &amp;lt;p className=&quot;font-medium text-gray-900&quot;&amp;gt;
                  {todo.title}
                &amp;lt;/p&amp;gt;
              &amp;lt;/td&amp;gt;

              &amp;lt;td className=&quot;px-6 py-4&quot;&amp;gt;
                &amp;lt;span
                  className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${todo.completed
                    ? &quot;bg-green-100 text-green-700&quot;
                    : &quot;bg-yellow-100 text-yellow-700&quot;
                    }`}
                &amp;gt;
                  {todo.completed ? &quot;Completed&quot; : &quot;Pending&quot;}
                &amp;lt;/span&amp;gt;
              &amp;lt;/td&amp;gt;

              &amp;lt;td className=&quot;px-6 py-4&quot;&amp;gt;
                &amp;lt;TodoActions todoId={todo.id} isCompleted={todo.completed} /&amp;gt;
              &amp;lt;/td&amp;gt;
            &amp;lt;/tr&amp;gt;
          ))}
        &amp;lt;/tbody&amp;gt;
      &amp;lt;/table&amp;gt;

      {data?.length === 0 &amp;amp;&amp;amp; (
        &amp;lt;div className=&quot;py-10 text-center text-sm text-gray-500&quot;&amp;gt;
          No todos found
        &amp;lt;/div&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We’re fetching todos in descending order so that the latest todo comes at top.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://devstacked.tech/supabase-todo-app/image1.png&quot; alt=&quot;Todo App Home Page&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;create-todo-actions-component&quot;&gt;Create Todo Actions Component&lt;/h3&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/components/todo-actions.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;'use client';

import { handleTodoAction } from &quot;@/actions/todo&quot;;
import { useActionState } from &quot;react&quot;;

const TodoActions = ({ todoId, isCompleted }: { todoId: string; isCompleted: boolean; }) =&amp;gt; {
    const [_, action, isPending] = useActionState(handleTodoAction, undefined);

    const completeTodo = () =&amp;gt; action({ todoId, action: &quot;complete&quot; });
    const deleteTodo = () =&amp;gt; action({ todoId, action: &quot;delete&quot; });

    return (
        &amp;lt;form className=&quot;flex justify-end gap-3&quot;&amp;gt;
            {!isCompleted &amp;amp;&amp;amp;
                &amp;lt;button
                    formAction={completeTodo}
                    disabled={isPending}
                    className=&quot;rounded-lg disabled:opacity-50 bg-blue-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-600&quot;
                &amp;gt;
                    Complete
                &amp;lt;/button&amp;gt;
            }
            &amp;lt;button
                formAction={deleteTodo}
                disabled={isPending}
                className=&quot;rounded-lg disabled:opacity-50 bg-red-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-600&quot;
            &amp;gt;
                Delete
            &amp;lt;/button&amp;gt;
        &amp;lt;/form&amp;gt;
    )
}

export default TodoActions
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;complete-and-delete-todo-action&quot;&gt;Complete and Delete Todo Action&lt;/h3&gt;
&lt;p&gt;Add inside:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;/src/actions/todo.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { revalidatePath } from &quot;next/cache&quot;;

export async function handleTodoAction(prev: unknown, data: { todoId: string; action: &quot;delete&quot; | &quot;complete&quot; }) {

    const { todoId, action } = data;

    if (!todoId) return;

    const supabase = await createClient()
    const { data: { user } } = await supabase.auth.getUser();

    if (!user) return;

    if (action === 'delete') {
        await supabase
            .from(&quot;todos&quot;)
            .delete()
            .eq(&quot;id&quot;, todoId)
            .eq(&quot;user_id&quot;, user.id);
    } else {
        await supabase
            .from(&quot;todos&quot;)
            .update({
                completed: true,
            })
            .eq(&quot;id&quot;, todoId)
            .eq(&quot;user_id&quot;, user.id);
    }
    revalidatePath('/');
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-result&quot;&gt;Final Result&lt;/h3&gt;
&lt;p&gt;You now have a fully functional Todo App with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Next.js 16&lt;/li&gt;
&lt;li&gt;Supabase Authentication&lt;/li&gt;
&lt;li&gt;Protected Routes&lt;/li&gt;
&lt;li&gt;Server Actions&lt;/li&gt;
&lt;li&gt;Zod Validation&lt;/li&gt;
&lt;li&gt;Secure RLS Policies&lt;/li&gt;
&lt;li&gt;CRUD Operations&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;This project demonstrates a modern full-stack architecture using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Next.js 16&lt;/li&gt;
&lt;li&gt;Server Actions&lt;/li&gt;
&lt;li&gt;Supabase&lt;/li&gt;
&lt;li&gt;RLS&lt;/li&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even though it’s a simple Todo App, the structure is production-ready and scalable.&lt;/p&gt;
&lt;p&gt;You can now build larger applications using the same architecture and patterns.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;useful-resources&quot;&gt;Useful Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://nextjs.org/docs/app/getting-started/mutating-data#what-are-server-functions&quot;&gt;Next.js Server Actions Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://supabase.com/docs/guides/database/postgres/row-level-security&quot;&gt;Row Level Security RLS Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://react.dev/reference/react/useActionState&quot;&gt;React useActionState Hook Docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;Source Code: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/Todo-App&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23100/build-a-todo-app-with-next-js-16-and-supabase-2026-guide</guid>
<pubDate>Tue, 21 Jul 2026 04:06:26 +0000</pubDate>
</item>
<item>
<title>Neon vs Supabase: Feature Comparison, Pros</title>
<link>https://coderlegion.com/21032/neon-vs-supabase-feature-comparison-pros</link>
<description>&lt;p&gt;A detailed technical comparison of Neon and Supabase as managed Postgres platforms in 2026. Neon is a serverless Postgres engine with disaggregated compute/storage, copy-on-write branching, autoscaling, and scale-to-zero billing. Supabase is a backend-as-a-service bundling auth, realtime, storage, edge functions, and auto-generated APIs around a dedicated Postgres VM. Key differences covered include branching semantics (Neon's full-data COW vs Supabase's schema-only branches), pricing models (usage-based vs fixed per-project), connection pooling approaches, PITR, read replicas, AI/MCP tooling, and application-layer services. The verdict: choose Neon for pure serverless Postgres needs (CI environments, bursty workloads, RDS migrations, agent tooling); choose Supabase when the bundled BaaS stack saves meaningful integration time.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21032/neon-vs-supabase-feature-comparison-pros</guid>
<pubDate>Sat, 20 Jun 2026 14:10:00 +0000</pubDate>
</item>
<item>
<title>Eunify: Cross-Platform Continuity System for Android and MacOS</title>
<link>https://coderlegion.com/18178/eunify-cross-platform-continuity-system-for-android-and-macos</link>
<description>&lt;blockquote&gt;&lt;p&gt;I Was Just Solving a Problem that I Face as an Android Phone User&lt;br&gt;
Working With MacOS. I Am Sure There's Someone Else Out There With&lt;br&gt;
Similar Challenges&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I developed Eunify, a continuity platform engineered to create a seamless, native-feeling ecosystem between macOS and Android. By bridging these two distinct operating systems, Eunify eliminates workflow friction and ensures uninterrupted cross-device synchronization for productivity and communication.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Core Features&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Universal Clipboard: Instantly synchronize your clipboard across operating systems. Text or media copied on your macOS machine is immediately available to paste on your Android device.&lt;/p&gt;
&lt;p&gt;Fluid Browser Handoff: Maintain uninterrupted browsing sessions across devices. You can drag and drop an active tab into Eunify on macOS to instantly launch it on your Android device, or push a tab from Android to pop up seamlessly on your Mac.&lt;/p&gt;
&lt;p&gt;Bidirectional File Transfer: Securely and rapidly send files directly back and forth between your macOS and Android devices without relying on external cloud services.&lt;/p&gt;
&lt;p&gt;Biometric Authentication Integration: Enhance your security workflow by leveraging your Android device's native biometric sensors to unlock your macOS laptop.&lt;/p&gt;
&lt;p&gt;Video Call Continuity: Effortlessly hand off active video calls from your macOS machine to your Android device, allowing you to transition from your desk to mobile without dropping the connection.&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=6697081181013665602&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7464064703061912304&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5071500663947463985&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7189078171734645109&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7666383216313576231&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11077430319582350407&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=12703508908033091367&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=783774772349424263&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5679720936373908046&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18178/eunify-cross-platform-continuity-system-for-android-and-macos</guid>
<pubDate>Sun, 17 May 2026 22:12:01 +0000</pubDate>
</item>
<item>
<title>Trial by Fire: From Garbage Excel to Relational Graph with Python and Pandas</title>
<link>https://coderlegion.com/15944/trial-by-fire-from-garbage-excel-to-relational-graph-with-python-and-pandas</link>
<description>&lt;h3 id=&quot;1-the-hook-industrial-data-entropy&quot;&gt;1. The Hook: Industrial Data Entropy&lt;/h3&gt;
&lt;p&gt;In standard academic theory, data sets are inherently clean. In the active reality of the industrial supply chain, obsolete ERPs continually export garbage arrays.&lt;/p&gt;
&lt;p&gt;Receiving a flat Bill of Materials systematically exported from a legacy database immediately binds you to processing massive structural entropy: entirely void parameter cells, anomalous blank spacing hidden inside critical part numbers (e.g., &lt;code&gt;&quot; SN74LS00N &quot;&lt;/code&gt;), unstandardized component manufacturer nomenclatures (inconsistently shifting between capitals and disparate acronyms like &quot;ti&quot;), and severe mixed data typing where strict numerals conflict natively with raw text variables.&lt;/p&gt;
&lt;p&gt;Pushing this raw flat algorithmic entropy directly forward into a live relational matrix triggers a complete referential integrity failure. The process inherently requires deploying a clean staging environment.&lt;/p&gt;
&lt;h3 id=&quot;2-the-etl-pipeline-extract-transform-load-&quot;&gt;2. The ETL Pipeline (Extract, Transform, Load)&lt;/h3&gt;
&lt;p&gt;Securing the database perimeter requires a rigorous intermediate layer. We employ &lt;strong&gt;Pandas&lt;/strong&gt;, effectively operating as the strictly objective gatekeeper to our foundational PostgreSQL database.&lt;/p&gt;
&lt;p&gt;Before a solitary piece of hardware telemetry reaches the backend schemas, the dedicated Python layer strictly enforces dataset standardization. We routinely execute hard operations securely using &lt;code&gt;.str.strip()&lt;/code&gt;, &lt;code&gt;.str.title()&lt;/code&gt;, and &lt;code&gt;.fillna()&lt;/code&gt; to properly homogenize external manufacturer part numbers (MPNs), completely delete volatile residual spaces, and explicitly enforce explicit valid ENUM classifications replacing null lifecycle constraints natively prior to API linkage.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def load_and_clean_data(filepath: str) -&amp;gt; pd.DataFrame:
    df = pd.read_csv(filepath)
    
    # 1. Radical space elimination and uppercase standardization (Avoids hardcoding)
    df['Manufacturer_PN'] = df['Manufacturer_PN'].str.strip().str.upper()
    df['Manufacturer'] = df['Manufacturer'].str.strip().str.upper()
    
    # 2. Enforced data typing (Guaranteed numerical quantities)
    df['Quantity_per_Assembly'] = pd.to_numeric(
        df['Quantity_per_Assembly'].astype(str).str.strip(), 
        errors='coerce'
    ).fillna(1).astype(int)
    
    # 3. Containment of missing values
    df['Lifecycle'] = df['Lifecycle'].replace(r'^\s*$', np.nan, regex=True).fillna('Active')
    
    return df
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;3-the-graph-challenge-shattering-the-flat-table-&quot;&gt;3. The Graph Challenge (Shattering the Flat Table)&lt;/h3&gt;
&lt;p&gt;A Bill of Materials mathematically operates internally as a hierarchical tree structure natively, not a flat dimension stack.&lt;/p&gt;
&lt;p&gt;In order to systematically populate the highly restrictive &lt;code&gt;bom_lines&lt;/code&gt; array cleanly avoiding the database exclusive &lt;code&gt;CHECK&lt;/code&gt; integrity blocker explicitly coded earlier, the python automation segregates and systematically separates the initial two-dimensional &lt;em&gt;DataFrame&lt;/em&gt; sequentially into two independent logical grouping vectors.&lt;/p&gt;
&lt;p&gt;Applying this distinct data segmentation strictly ensures the entire tree graphs properly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Loop 1:&lt;/strong&gt; Explicitly tying Final Parent Products (&lt;code&gt;End_Product_SKU&lt;/code&gt;) entirely to specific internal Subassembly containers (&lt;code&gt;Assembly_PN&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loop 2:&lt;/strong&gt; Structurally tying Internal Subassemblies (&lt;code&gt;Assembly_PN&lt;/code&gt;) strictly natively downward to explicit Base Component items (&lt;code&gt;Component_PN&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This absolute directional differentiation operates cleanly resolving integration faults natively satisfying the mutually exclusive table constraints enforced inside Supabase, correctly mimicking highly accurate factory layout trees algorithmically.&lt;/p&gt;
&lt;h3 id=&quot;4-the-golden-rule-ingestion-idempotency&quot;&gt;4. The Golden Rule: Ingestion Idempotency&lt;/h3&gt;
&lt;p&gt;Before a standard data module successfully reaches enterprise standard operation it strictly requires satisfying system &lt;strong&gt;Idempotency&lt;/strong&gt;. This establishes directly that any localized operations engineer can forcefully execute the extraction module recursively infinite sequential times utilizing identical hardware array data completely strictly avoiding destroying structural records nor accidentally generating unconstrained graph hierarchy duplication.&lt;/p&gt;
&lt;p&gt;Exploiting primitive &lt;code&gt;INSERT&lt;/code&gt; API calls natively corrupts the database integrity by duplicating the graph edges. Instead, the implementation forcefully deploys the &lt;code&gt;upsert()&lt;/code&gt; functions logically integrated seamlessly through the Supabase SDK backend architecture utilizing strictly declared Postgres &lt;code&gt;UNIQUE&lt;/code&gt; keys firmly established systematically across the specific domains (&lt;code&gt;sku&lt;/code&gt; directly crossing global product ranges, &lt;code&gt;internal_pn&lt;/code&gt; binding isolated component IDs, and &lt;code&gt;mpn&lt;/code&gt; natively locking foreign market parts respectively).&lt;/p&gt;
&lt;p&gt;Executing automated telemetry pushes immediately executes cleanly verifying redundant array vectors explicitly reporting live structural compliance seamlessly across loop iterations:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;--- Ingestando Products (Idempotente) ---
Upserted 3 products.
--- Ingestando Internal Parts (Idempotente) ---
Upserted 18 internal parts (assemblies &amp;amp; components).
--- Ingestando Manufacturer Parts Telemetry (Idempotente) ---
Upserted 16 manufacturer external parts.
--- Ingestando AML (Mapeo Interno -&amp;gt; Externo) ---
No new AML links to insert (Idempotent success).
--- Ingestando BOM Lines (Grafo Bidireccional Segregado) ---
Mapeando relaciones: Producto Final -&amp;gt; Subensamblaje...
Mapeando relaciones: Subensamblaje -&amp;gt; Componente...
El Grafo BOM está completamente sincronizado (Idempotent success).

[OK] Migración a entorno relacional completada al 100%.
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-sandbox-execute-it-yourself&quot;&gt;The Sandbox: Execute it Yourself&lt;/h4&gt;
&lt;p&gt;In Operations Engineering, code is worth more than theory. I have prepared an interactive and secure environment (Zero Friction) for you to test this ETL pipeline.&lt;/p&gt;
&lt;p&gt;You don't need to install anything or configure databases. The script will spin up an SQL engine in your browser's memory, ingest a corrupt CSV file, cleanse it using Pandas, and build the relational tree in milliseconds, demonstrating idempotency live.&lt;/p&gt;
&lt;p&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://colab.research.google.com/drive/1kMLX2RexPSZVHXuXURZ1VnF_w2nRPgxg?usp=sharing&quot;&gt;Access the Interactive Google Colab Here&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;5-closing-amp-cta-towards-the-ai-brain-&quot;&gt;5. Closing &amp;amp; CTA (Towards the AI Brain)&lt;/h3&gt;
&lt;p&gt;The system structural data logic completely establishes pure stability. We strictly transported fundamentally corrupt, stagnant standard tabular Excel files completely across explicit programming loops directly natively injecting refined node values natively filling a vastly superior PostgreSQL relational database firmly.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The core foundations remain entirely structural and secure. We possess a natively stable IEC 62402 compliant relational architecture structurally fused directly with a robust Python processing ETL node securely operating live. Crossing into the incoming operational module (Block 3), we firmly awaken complex systematic intelligence cleanly processing logic directly on hardware trees. We deploy strictly scalable Agentic AI parameters (LangChain / CrewAI) comprehensively actively connected inside the unified database constantly autonomously identifying internal financial stress resulting dynamically processing external global EOL silicon alerts. Subscribe consistently witnessing the Agentic Radar launch sequence.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15944/trial-by-fire-from-garbage-excel-to-relational-graph-with-python-and-pandas</guid>
<pubDate>Mon, 27 Apr 2026 05:59:10 +0000</pubDate>
</item>
<item>
<title>The Risk Map: Architecting an Obsolescence-Immune Data Foundation</title>
<link>https://coderlegion.com/15801/the-risk-map-architecting-an-obsolescence-immune-data-foundation</link>
<description>&lt;h3 id=&quot;1-the-flat-table-trap-excel-is-dead-&quot;&gt;1. The Flat Table Trap (Excel is Dead)&lt;/h3&gt;
&lt;p&gt;Managing an industrial &lt;em&gt;Bill of Materials&lt;/em&gt; (BOM) through spreadsheets is a structural deficiency. Excel provides a two-dimensional environment for a three-dimensional problem. Hardware engineering and manufacturing dependencies operate under a graph logic.&lt;/p&gt;
&lt;p&gt;When the procurement department receives an &lt;em&gt;End of Life&lt;/em&gt; (EOL) alert for a component, calculating the volumetric impact by searching text across multiple static documents introduces operational latency.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;UNE-EN IEC 62402:2019&lt;/strong&gt; standard, in its &lt;strong&gt;Clause 8.10 (Data Acquisition)&lt;/strong&gt;, establishes the requirement to maintain &quot;a list of configuration sub items within an item&quot; alongside &quot;the identification of the items and sub items details: manufacturer, part number and specification&quot;. Achieving the level of parametric traceability demanded by the standard requires the design of a relational data model.&lt;/p&gt;
&lt;h3 id=&quot;2-modeling-reality-the-graph-approach-the-internal-environment-&quot;&gt;2. Modeling Reality: The Graph Approach (The Internal Environment)&lt;/h3&gt;
&lt;p&gt;A bill of materials is a dependency tree. To structure the internal manufacturing environment data in PostgreSQL, we define three main indexed tables:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;products&lt;/code&gt;&lt;/strong&gt;: Represents the end products. Includes the &lt;code&gt;gross_margin&lt;/code&gt; parameter, required to correlate component supply risk with the financial impact on the P&amp;amp;L.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;internal_parts&lt;/code&gt;&lt;/strong&gt;: A consolidated register of components, raw materials, and sub-assemblies defined by engineering. Uses a global unique identifier (&lt;code&gt;internal_pn&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;bom_lines&lt;/code&gt;&lt;/strong&gt;: Implements the hierarchical relationship. Defines dependencies through mutually exclusive foreign keys (&lt;code&gt;parent_product_id&lt;/code&gt; or &lt;code&gt;parent_assembly_id&lt;/code&gt;) to a child component (&lt;code&gt;child_pn&lt;/code&gt;), specifying a &lt;code&gt;quantity&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Technical Validation:&lt;/strong&gt; Normalizing the data into a graph structure enables the use of recursive algorithms capable of calculating total exposure in milliseconds. If &quot;Equipment A&quot; requires 4 capacitors and &quot;Equipment B&quot; requires 12, a shortage alert on the component node propagates mathematically and automatically up the tree, cross-referencing totals against the order backlog (SLAs).&lt;/p&gt;
&lt;h3 id=&quot;3-the-firewall-the-aml-table-approved-manufacturer-list-&quot;&gt;3. The Firewall: The AML Table (Approved Manufacturer List)&lt;/h3&gt;
&lt;p&gt;A core principle of industrial inventory integrity is to prevent the mixing of internal nomenclature with dynamic global market references. Omitting this separation generates traceability inconsistencies.&lt;/p&gt;
&lt;p&gt;The architecture implements the &lt;strong&gt;&lt;code&gt;aml&lt;/code&gt; (Approved Manufacturer List)&lt;/strong&gt; table as an interface between internal hardware design and the supply chain.&lt;/p&gt;
&lt;p&gt;This table enables the &lt;strong&gt;Dual-Sourcing&lt;/strong&gt; strategy. If the bill of materials specifies the component &lt;code&gt;CAP-10K&lt;/code&gt; (ERP code), the &lt;code&gt;aml&lt;/code&gt; table relates this internal node to both a Texas Instruments reference (&lt;code&gt;preference_level: Primary&lt;/code&gt;) and a validated equivalent from Analog Devices (&lt;code&gt;preference_level: Secondary&lt;/code&gt;). Upon a supply interruption from the primary source, the relational logic allows switching demand to the secondary source without altering the base BOM.&lt;/p&gt;
&lt;h3 id=&quot;4-the-integrated-radar-the-external-environment-&quot;&gt;4. The Integrated Radar (The External Environment)&lt;/h3&gt;
&lt;p&gt;Integration with global commercial dynamics is consolidated in the &lt;strong&gt;&lt;code&gt;manufacturer_parts&lt;/code&gt;&lt;/strong&gt; table.&lt;/p&gt;
&lt;p&gt;This entity receives telemetry flows from market aggregators (&lt;em&gt;SiliconExpert&lt;/em&gt;, &lt;em&gt;IHS Markit&lt;/em&gt;, &lt;em&gt;Accuris&lt;/em&gt;) through asynchronous integrations. It maintains an updated record of the manufacturer part numbers (&lt;code&gt;mpn&lt;/code&gt;), their lifecycle state (&lt;code&gt;lifecycle_status&lt;/code&gt;), and the estimated obsolescence date (&lt;code&gt;estimated_eol_date&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/obs_part3_architecture/architecture.png&quot; alt=&quot;architecture&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;5-data-segmentation-and-intellectual-property-rls-in-supabase-&quot;&gt;5. Data Segmentation and Intellectual Property (RLS in Supabase)&lt;/h3&gt;
&lt;p&gt;Deploying this model involves managing Intellectual Property (IP) assets. Accidental or unauthorized exposure of the complete product topology is an unacceptable information security risk. Leveraging the Supabase platform allows implementing security architectures at the data layer.&lt;/p&gt;
&lt;p&gt;To prevent external generative models (LLMs) or unprivileged users from reconstructing the complete BOM dependencies, we apply &lt;strong&gt;Row Level Security (RLS)&lt;/strong&gt; directly in PostgreSQL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Security: Row Level Security (RLS) policies - IP Protection
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE bom_lines ENABLE ROW LEVEL SECURITY;

-- Allow general read access to authenticated logic
CREATE POLICY &quot;Allow read access to authenticated users&quot; 
ON products FOR SELECT TO authenticated USING (true);

-- Restrict mutations (insert/update/delete) on products and BOM lines to Admin role exclusively
CREATE POLICY &quot;Restrict products mutations to admins&quot; 
ON products FOR ALL TO authenticated 
USING ((auth.jwt() -&amp;gt;&amp;gt; 'role') = 'admin');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The policies restrict any modifications to the &lt;code&gt;products&lt;/code&gt; and &lt;code&gt;bom_lines&lt;/code&gt; tables to the &quot;Admin&quot; role. Additionally, the architectural design dictates that the database must parse the mathematical expansion of the tree internally and provide the external AI Agent with only the aggregated risk value, without returning the topological structure.&lt;/p&gt;
&lt;h3 id=&quot;6-next-steps&quot;&gt;6. Next Steps&lt;/h3&gt;
&lt;p&gt;The relational database schema for obsolescence management is defined based on modular integrity principles.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The next step in the data architecture process will focus on integration automation. We will evaluate development in Python, using the Pandas library, to engineer an extraction, transformation, and loading (ETL) pipeline capable of ingesting BOM lists in Excel format and structurally populating them into this Supabase ecosystem. Subscribe to access this operational phase.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15801/the-risk-map-architecting-an-obsolescence-immune-data-foundation</guid>
<pubDate>Sat, 25 Apr 2026 07:47:17 +0000</pubDate>
</item>
<item>
<title>S&amp;OP Engineering III: The End of Excel (Linear Programming for Supply Planning)</title>
<link>https://coderlegion.com/13794/sop-engineering-iii-the-end-of-excel-linear-programming-for-supply-planning</link>
<description>&lt;p&gt;&quot;We always want 4 weeks of coverage.&quot; This phrase, repeated like a mantra in every S&amp;amp;OP meeting on the planet, is financially toxic.&lt;/p&gt;
&lt;p&gt;Why? Because it's a &lt;strong&gt;fixed rule applied to a dynamic system&lt;/strong&gt;. If your demand in January is 200 units and in July is 20, you're forcing yourself to maintain 800 and 80 units respectively &quot;just in case.&quot; January falls short. July immobilizes capital for no reason.&lt;/p&gt;
&lt;p&gt;The alternative isn't more sophisticated intuition. It's &lt;strong&gt;mathematics&lt;/strong&gt;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Executive Summary:&lt;/strong&gt; In this chapter, we connect the probabilistic forecast from &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/sop-engineering-part2-forecasting/&quot;&gt;Chapter 2&lt;/a&gt; with a Linear Programming engine (PuLP) that calculates the exact production plan minimizing total cost (production + storage) while respecting Safety Stock constraints. We move from passive prediction to active prescription.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;from-forecast-to-decision-architecture&quot;&gt;From Forecast to Decision: Architecture&lt;/h3&gt;
&lt;p&gt;In &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/sop_engineering-data-hygiene/&quot;&gt;Chapter 1&lt;/a&gt; we cleaned the signal. In &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/sop-engineering-part2-forecasting/&quot;&gt;Chapter 2&lt;/a&gt; we predicted demand. Now we take the step Excel can't: &lt;strong&gt;optimize&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Our pipeline on GitHub connects to Supabase, reads the &lt;code&gt;demand_forecasts&lt;/code&gt; table (the future we predicted), and generates a new &lt;code&gt;supply_plans&lt;/code&gt; table. The system has evolved from &lt;strong&gt;descriptive&lt;/strong&gt; (what happened?) to &lt;strong&gt;predictive&lt;/strong&gt; (what will happen?) and now to &lt;strong&gt;prescriptive&lt;/strong&gt; (what should I do?).&lt;/p&gt;
&lt;p&gt;This is Operations Research. The same discipline that optimizes airline routes, military logistics, and global supply chains. And we run it with 50 lines of Python.&lt;/p&gt;
&lt;h3 id=&quot;the-mathematics-of-business&quot;&gt;The Mathematics of Business&lt;/h3&gt;
&lt;p&gt;We're not going to hide the equations. They're the heart of the decision. Here's the core fragment from our &lt;code&gt;SupplyOptimizer&lt;/code&gt; class:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Objective Function: Minimize total cost
problem += pulp.lpSum(
    production_cost * production[t] + holding_cost * inventory[t]
    for t in range(T)
), &quot;Total_Cost&quot;

# Constraint: Inventory Balance (Conservation of Mass)
for t in range(T):
    prev_inv = initial_inventory if t == 0 else inventory[t - 1]
    problem += (
        inventory[t] == prev_inv + production[t] - demand[t],
        f&quot;Balance_t{t}&quot;
    )

# Constraint: Safety Stock (Risk Policy)
for t in range(T):
    problem += (
        inventory[t] &amp;gt;= safety_stock,
        f&quot;SafetyStock_t{t}&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three engineering decisions worth explaining:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Objective Function&lt;/strong&gt; doesn't seek &quot;lots of stock&quot; or &quot;high production.&quot; It seeks the &lt;strong&gt;minimum financial cost&lt;/strong&gt;: the equilibrium between manufacturing (expensive) and storing (also expensive). The solver automatically finds the exact point where the combined cost is minimal.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Mass Balance&lt;/strong&gt; is a physical constraint: you can't sell what you don't have. Today's inventory equals yesterday's, plus what you produce today, minus what you sell. No magic. The equations forbid cheating.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Safety Stock&lt;/strong&gt; is the risk policy: never let inventory drop below a safety minimum. In our case, 1.5 months of average demand. The system calculates this, not a spreadsheet with a number pulled from thin air.&lt;/p&gt;
&lt;h3 id=&quot;the-master-plan-from-algorithm-to-business-decision&quot;&gt;The Master Plan: From Algorithm to Business Decision&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/sop-engineering-part3-optimization/pulp_optimization_plan.png&quot; alt=&quot;Master Production Schedule optimized with PuLP: production, inventory, and Safety Stock by month&quot;&gt;&lt;br&gt;
&lt;em&gt;This is what the Operations Director needs to see. The algorithm doesn't produce uniformly: if it detects a massive demand spike in one period, it decides to &quot;pre-build&quot; inventory in preceding months to flatten the production load. If Holding Cost is high, it keeps the warehouse empty and manufactures Just-in-Time. Excel doesn't do this on its own; mathematics does.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The result from our solver with test data:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Production cost:&lt;/strong&gt; €1,680&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Storage cost:&lt;/strong&gt; €540&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total optimized cost:&lt;/strong&gt; €2,220&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This number is not an estimate. It's the &lt;strong&gt;provable global minimum&lt;/strong&gt; given the constraints. If someone proposes a cheaper plan with the same parameters, they're violating a constraint.&lt;/p&gt;
&lt;h3 id=&quot;open-kitchen-play-with-the-solver&quot;&gt;Open Kitchen: Play with the Solver&lt;/h3&gt;
&lt;p&gt;I distrust theories that can't be put into practice. That's why I've prepared a Google Colab where you can run the optimizer on a snapshot of our real data.&lt;/p&gt;
&lt;p&gt;The most revealing experiment: &lt;strong&gt;change &lt;code&gt;holding_cost&lt;/code&gt; to an extremely high value&lt;/strong&gt; (e.g., €50/unit). Watch how the algorithm automatically decides to manufacture Just-in-Time and keep the warehouse practically empty. Then lower the production cost and watch it prefer to produce in bulk and store. Mathematics adapts. Excel's fixed rules don't.&lt;/p&gt;
&lt;p&gt; &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://colab.research.google.com/drive/1fF9DY-eHL13G0AFg_geNxQ6duEMOWy4N?usp=sharing&quot;&gt;Open the Interactive Google Colab&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Modify the costs, capacity constraints, Safety Stock. Do engineering, not faith.&lt;/p&gt;
&lt;h3 id=&quot;the-complete-chain-from-data-to-decisions&quot;&gt;The Complete Chain: From Data to Decisions&lt;/h3&gt;
&lt;p&gt;With this third chapter, we've built an end-to-end S&amp;amp;OP system that goes from a dirty ERP CSV to an optimal production plan:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/sop-engineering-part3-optimization/architecture.png&quot; alt=&quot;architecture&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;next-step-scaling-to-enterprise&quot;&gt;Next Step: Scaling to Enterprise&lt;/h3&gt;
&lt;p&gt;We now have the perfect plan in our database. But only for one product. What happens when you add 3 SKUs sharing the same factory?&lt;/p&gt;
&lt;p&gt;In &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/sop-engineering-part4-enterprise/&quot;&gt;Chapter 4&lt;/a&gt; we break the MVP: we inject multi-product data with radically different profiles, parallelize the forecasting with &lt;strong&gt;MLOps&lt;/strong&gt;, and build a unified Linear Programming model where products &lt;em&gt;compete mathematically&lt;/em&gt; for shared production capacity.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The difference between an Operations Director who plans and one who optimizes is an objective function between their intuition and reality.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/13794/sop-engineering-iii-the-end-of-excel-linear-programming-for-supply-planning</guid>
<pubDate>Wed, 01 Apr 2026 06:01:26 +0000</pubDate>
</item>
<item>
<title>S&amp;OP: Why Your Excel Is Lying to You (and How to Interrogate It with Python)</title>
<link>https://coderlegion.com/13465/sop-why-your-excel-is-lying-to-you-and-how-to-interrogate-it-with-python</link>
<description>&lt;p&gt;In S&amp;amp;OP (Sales &amp;amp; Operations Planning) meetings, opinions are often discussed instead of facts. &lt;em&gt;&quot;I think we'll sell more&quot;&lt;/em&gt;, &lt;em&gt;&quot;Last month was weird&quot;&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The root problem is not the lack of business vision, it's the &lt;strong&gt;lack of signal integrity&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Most supply chains are managed on spreadsheets that accept anything: dates as text, blank spaces, and typos that turn a 100-unit order into 100,000. When you feed your prediction algorithm with that &quot;garbage,&quot; you get amplified garbage (the financial &lt;em&gt;Bullwhip&lt;/em&gt; effect).&lt;/p&gt;
&lt;p&gt;Today we kick off the &lt;strong&gt;S&amp;amp;OP Engineering&lt;/strong&gt; series. We're not going to talk theory; we're going to build a data architecture that audits your business automatically.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/sop_engineering-data-hygiene/cover.png&quot; alt=&quot;S&amp;amp;OP Engineering Image&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-problem-signal-to-noise-ratio&quot;&gt;The Problem: Signal-to-Noise Ratio&lt;/h3&gt;
&lt;p&gt;In telecommunications (my original background), noise is any interference that corrupts the signal. In Supply Chain, &quot;noise&quot; is dirty data.&lt;/p&gt;
&lt;p&gt;If you don't filter the noise before planning demand, you're &lt;strong&gt;immobilizing capital&lt;/strong&gt;. An undetected &lt;em&gt;outlier&lt;/em&gt; is money on fire. If your algorithm sees a false spike of 100,000 units, it will order raw materials you don't need, burning cash and taking up warehouse space. Data hygiene is not 'cleaning', it's protecting your operating margin.&lt;/p&gt;
&lt;h4 id=&quot;the-visual-evidence&quot;&gt;The Visual Evidence&lt;/h4&gt;
&lt;p&gt;Before seeing a single line of code, look at the difference between what your ERP exports (top) and the statistical reality of your demand (bottom).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/sop_engineering-data-hygiene/sop_impact_chart.png&quot; alt=&quot;Data Hygiene Impact&quot;&gt;&lt;br&gt;
&lt;em&gt;Top: Raw data with human errors. Bottom: The clean signal ready for AI algorithms.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-solution-quality-valve-architecture&quot;&gt;The Solution: &quot;Quality Valve&quot; Architecture&lt;/h3&gt;
&lt;p&gt;To solve this, we apply &lt;strong&gt;First Principles Thinking&lt;/strong&gt;. We don't need to &quot;be more careful&quot; with Excel. We need a system that mathematically &lt;strong&gt;prohibits&lt;/strong&gt; the entry of dirty data into our &quot;Single Source of Truth.&quot;&lt;/p&gt;
&lt;p&gt;We've designed an automated pipeline with the following stack:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Brain:&lt;/strong&gt; Python (Pandas + Scipy) for statistical logic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Warehouse:&lt;/strong&gt; Supabase (PostgreSQL) as the &quot;Single Source of Truth.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agent:&lt;/strong&gt; A script that runs automatically when new files arrive.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-code-statistics-gt-intuition&quot;&gt;The Code: Statistics &amp;gt; Intuition&lt;/h4&gt;
&lt;p&gt;We don't use fixed rules (&quot;if greater than 1000, delete&quot;). We use statistics. We implement the &lt;strong&gt;Z-Score&lt;/strong&gt;, which measures how many standard deviations a data point is from the mean.&lt;/p&gt;
&lt;p&gt;If a sale has a &lt;code&gt;Z-Score &amp;gt; 3&lt;/code&gt; (it's more than 3 sigmas from normality), it's mathematically unlikely to be standard behavior. The system doesn't delete it (it could be a real sale), but it &lt;strong&gt;flags it for audit&lt;/strong&gt; and excludes it from automatic prediction.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Note&lt;/em&gt;: We use Z-Score assuming normality to simplify this example. In production scenarios with intermittent demand, we use methods like IQR (Interquartile Range) or MAD (Median Absolute Deviation) which are more robust for non-Gaussian distributions.&lt;/p&gt;
&lt;p&gt;[Image of box plot showing Interquartile Range (IQR) and outliers]&lt;/p&gt;
&lt;p&gt;Here's the core logic from our &lt;code&gt;SupplyChainSanitizer&lt;/code&gt; class:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def detect_outliers_zscore(self, threshold=3):
    &quot;&quot;&quot;
    Detects statistical anomalies using Z-Score.
    We don't delete the row (info loss), we label it.
    &quot;&quot;&quot;
    # Calculate the standard deviation of the signal
    z_scores = np.abs(stats.zscore(self.df['qty']))
    
    # Flag what is mathematically suspicious
    self.df['is_outlier'] = z_scores &amp;gt; threshold
    return self
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;open-kitchen-try-it-yourself&quot;&gt;Open Kitchen: Try It Yourself&lt;/h3&gt;
&lt;p&gt;As an engineer, I distrust what I can't execute. That's why I've isolated the cleaning logic in an &lt;a rel=&quot;nofollow&quot; href=&quot;https://colab.research.google.com/drive/13BxVyaw46BYWGz8LQQ4WE83KGrQf79jJ?usp=sharing&quot;&gt;interactive Colab Notebook&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You don't need to install Python or configure databases. I've prepared an ephemeral environment where you can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Generate a corrupt sales dataset (simulated).&lt;/li&gt;
&lt;li&gt;Run the &lt;code&gt;SupplyChainSanitizer&lt;/code&gt; cleaning engine.&lt;/li&gt;
&lt;li&gt;See how the algorithm detects and separates the noise.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Click the button, hit &quot;Play&quot; on the cells, and observe data engineering in action.&lt;/p&gt;
&lt;h3 id=&quot;production-architecture-behind-the-scenes-&quot;&gt;Production Architecture (Behind the Scenes)&lt;/h3&gt;
&lt;p&gt;For technical profiles interested in how this scales in a real company (Datalaria Core):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ingestion:&lt;/strong&gt; CSVs are uploaded to a private Bucket in Supabase Storage or a local database.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trigger:&lt;/strong&gt; A Python worker detects the file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Processing:&lt;/strong&gt; Executes the cleaning in memory (Docker Container).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Persistence:&lt;/strong&gt; Clean data is injected into PostgreSQL using Row Level Security (RLS) to ensure nobody can manually alter the financial history.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Security Note:&lt;/strong&gt; In production, we never connect scripts with superuser permissions. We use specific Service Roles and strict RLS policies to ensure supply chain integrity.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;data-flow-visualization&quot;&gt;Data Flow Visualization&lt;/h4&gt;
&lt;p&gt;The following diagram shows how &quot;dirty&quot; data passes through our &lt;strong&gt;Quality Valve&lt;/strong&gt; before reaching the Single Source of Truth:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/sop_engineering-data-hygiene/sop_part1_diagram.png&quot; alt=&quot;S&amp;amp;OP Engineering Image&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Legend:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Red:&lt;/strong&gt; Raw data with noise (the problem)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Green:&lt;/strong&gt; Clean signal ready for prediction&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Orange:&lt;/strong&gt; Anomalies labeled for human review&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blue:&lt;/strong&gt; Centralized warehouse (Supabase)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;next-step-scientific-prediction&quot;&gt;Next Step: Scientific Prediction&lt;/h3&gt;
&lt;p&gt;Now that we have a clean database (a pure signal), we're ready to look into the future.&lt;/p&gt;
&lt;p&gt;In the next chapter of the series, we'll connect this clean table with &lt;strong&gt;Facebook Prophet&lt;/strong&gt; to generate probabilistic demand forecasts, forever abandoning Excel's simple moving averages.&lt;/p&gt;
&lt;p&gt;Subscribe to receive Chapter 2: &lt;em&gt;&quot;Demand Planning: From Guessing to Probability&quot;&lt;/em&gt;.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/13465/sop-why-your-excel-is-lying-to-you-and-how-to-interrogate-it-with-python</guid>
<pubDate>Wed, 25 Mar 2026 07:22:37 +0000</pubDate>
</item>
<item>
<title>From Zero to Hero: Create a Cyberpunk Snake Game with Real-Time Ranking using Supabase and Vanilla J</title>
<link>https://coderlegion.com/12990/from-zero-hero-create-cyberpunk-snake-game-with-real-time-ranking-using-supabase-vanilla</link>
<description>&lt;p&gt;Do you remember the classic Snake from the old Nokias? Now imagine it with neon lights, synthesized music, explosive particles, and most importantly: &lt;strong&gt;connected to the cloud&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Web game development has experienced a renaissance thanks to the power of HTML5 Canvas and &lt;strong&gt;Serverless&lt;/strong&gt; tools. In the past, if you wanted to save your players' scores to create a global leaderboard, you needed to set up a server, configure a REST API, manage databases, and pay for hosting. Today, thanks to Backend-as-a-Service (BaaS), we can do it in minutes.&lt;/p&gt;
&lt;p&gt;In this tutorial, we are going to build &lt;strong&gt;&quot;Neon Snake&quot;&lt;/strong&gt;, an aesthetic and functional web arcade game that uses &lt;strong&gt;Supabase&lt;/strong&gt; (an Open Source alternative to Firebase) to persist data in PostgreSQL.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/game_snake/imagen_snake_game.png&quot; alt=&quot;Conceptual image of Snake Game&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;-the-tech-stack&quot;&gt;️ The Tech Stack&lt;/h3&gt;
&lt;p&gt;We're going to keep it simple but powerful. No heavy frameworks, just the purity of the web:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Frontend:&lt;/strong&gt; HTML5 Canvas + Vanilla JavaScript (ES6+).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Styles:&lt;/strong&gt; Tailwind CSS (via CDN) for the user interface.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backend:&lt;/strong&gt; Supabase (PostgreSQL + Realtime).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audio:&lt;/strong&gt; Web Audio API for code-generated sound effects.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-setting-up-the-backend-with-supabase&quot;&gt;Step 1: Setting up the Backend with Supabase&lt;/h3&gt;
&lt;p&gt;Before writing a single line of JavaScript, we need our brain in the cloud. Supabase offers us a real PostgreSQL database with an automatically generated API.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a free account at &lt;a rel=&quot;nofollow&quot; href=&quot;https://supabase.com&quot;&gt;supabase.com&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Create a new project called &lt;code&gt;NeonSnake&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Go to the &lt;strong&gt;Table Editor&lt;/strong&gt; section and create a new table called &lt;code&gt;snake_scores&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;the-magic-of-sql&quot;&gt;The Magic of SQL&lt;/h4&gt;
&lt;p&gt;To streamline the process, Supabase allows you to execute SQL commands directly. Go to the &lt;strong&gt;SQL Editor&lt;/strong&gt; and paste the following code. This will create the table structure and configure the security policies (RLS - Row Level Security) so your game can read and write data publicly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 1. Create the scores table
create table snake_scores (
  id bigint generated by default as identity primary key,
  username text not null,
  score integer not null,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null
);

-- 2. Enable Row Level Security (RLS)
alter table snake_scores enable row level security;

-- 3. Policy to allow anyone to view the ranking (Public Read)
create policy &quot;Ver ranking publico&quot;
on snake_scores for select
using ( true );

-- 4. Policy to allow anyone to save their record (Public Write)
create policy &quot;Guardar puntuacion&quot;
on snake_scores for insert
with check ( true );
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Security Note:&lt;/strong&gt; In a commercial game, we would use full user authentication. For this arcade-style demo, we allow public (anonymous) access to reduce friction so anyone can play and log their record instantly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Finally, go to &lt;strong&gt;Settings &amp;gt; API&lt;/strong&gt; and copy your &lt;code&gt;Project URL&lt;/code&gt; and your &lt;code&gt;anon public key&lt;/code&gt;. You will need them shortly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-the-cyberpunk-frontend-html5-canvas-&quot;&gt;Step 2: The Cyberpunk Frontend (HTML5 + Canvas)&lt;/h3&gt;
&lt;p&gt;Our game will live in a single &lt;code&gt;index.html&lt;/code&gt; file. We will use &lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the floating menus (Game Over, Start Screen) and the &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; element to render the game at 60 FPS.&lt;/p&gt;
&lt;p&gt;The basic structure is as follows:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;title&amp;gt;Neon Snake 2077&amp;lt;/title&amp;gt;
    &amp;lt;script src=&quot;[https://cdn.tailwindcss.com](https://cdn.tailwindcss.com)&quot;&amp;gt;&amp;lt;/script&amp;gt;
    &amp;lt;style&amp;gt;
        @import url('[https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&amp;amp;display=swap](https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&amp;amp;display=swap)');
        body { font-family: 'Orbitron', sans-serif; background-color: #050505; }
        /* Neon glow effect for the canvas */
        canvas { box-shadow: 0 0 20px #0ff; border: 2px solid #0ff; }
    &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body class=&quot;h-screen w-full flex items-center justify-center overflow-hidden bg-gray-900&quot;&amp;gt;

    &amp;lt;div class=&quot;relative&quot;&amp;gt;
        &amp;lt;canvas id=&quot;gameCanvas&quot; width=&quot;400&quot; height=&quot;400&quot;&amp;gt;&amp;lt;/canvas&amp;gt;
        
        &amp;lt;div id=&quot;gameOverMenu&quot; class=&quot;hidden absolute inset-0 bg-black/90 flex flex-col items-center justify-center z-10&quot;&amp;gt;
            &amp;lt;h2 class=&quot;text-4xl text-red-500 font-bold mb-4 drop-shadow-[0_0_10px_rgba(239,68,68,0.8)]&quot;&amp;gt;SYSTEM FAILURE&amp;lt;/h2&amp;gt;
            &amp;lt;input type=&quot;text&quot; id=&quot;usernameInput&quot; placeholder=&quot;Enter ID...&quot; class=&quot;bg-gray-800 text-cyan-400 p-2 rounded border border-cyan-500 mb-4 text-center outline-none&quot;&amp;gt;
            &amp;lt;button id=&quot;saveBtn&quot; class=&quot;bg-cyan-600 hover:bg-cyan-500 text-white font-bold py-2 px-4 rounded shadow-[0_0_15px_#06b6d4] transition-all&quot;&amp;gt;
                UPLOAD DATA
            &amp;lt;/button&amp;gt;
            
            &amp;lt;div id=&quot;leaderboard&quot; class=&quot;mt-6 text-sm text-gray-300 w-64 text-left h-40 overflow-y-auto custom-scrollbar&quot;&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;script type=&quot;module&quot;&amp;gt;
        import { createClient } from '[https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm](https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm)'
        
        // ... Game Logic here (See step 3) ...
    &amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;game-logic-beyond-eating-apples&quot;&gt;Game Logic: Beyond Eating Apples&lt;/h4&gt;
&lt;p&gt;To give it that &quot;Pro&quot; touch and differentiate it from a basic tutorial, we implement several key mechanics in JavaScript:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Animation Loop:&lt;/strong&gt; Using &lt;code&gt;requestAnimationFrame&lt;/code&gt; to maintain fluidity at 60 FPS.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Particle System:&lt;/strong&gt; An array of objects that spawn at the &quot;food&quot; coordinates when eaten and fade gradually, simulating a data explosion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dynamic Firewalls:&lt;/strong&gt; From level 5 onwards, we generate static obstacles that the player must dodge, progressively increasing the difficulty.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hybrid Controls:&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PC:&lt;/strong&gt; We listen for &lt;code&gt;keydown&lt;/code&gt; events for directional arrows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mobile:&lt;/strong&gt; We capture &lt;code&gt;touchstart&lt;/code&gt; and &lt;code&gt;touchend&lt;/code&gt; to calculate the gesture direction (&lt;em&gt;swipe&lt;/em&gt;) and move the snake.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-connecting-the-dots-integration-with-supabase-&quot;&gt;Step 3: Connecting the Dots (Integration with Supabase)&lt;/h3&gt;
&lt;p&gt;Here is where the Backend-as-a-Service magic happens. We are going to connect our Canvas to the PostgreSQL database. Inside our &lt;code&gt;&amp;lt;script type=&quot;module&quot;&amp;gt;&lt;/code&gt; tag:&lt;/p&gt;
&lt;h4 id=&quot;1-client-initialization&quot;&gt;1. Client Initialization&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const supabaseUrl = 'YOUR_SUPABASE_PROJECT_URL';
const supabaseKey = 'YOUR_SUPABASE_ANON_KEY';
const supabase = createClient(supabaseUrl, supabaseKey);
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;2-the-game-over-flow&quot;&gt;2. The &quot;Game Over&quot; Flow&lt;/h4&gt;
&lt;p&gt;When the player loses and hits &quot;UPLOAD DATA&quot;, we need to ensure the data is saved before showing the leaderboard. We will use &lt;code&gt;async/await&lt;/code&gt; to control this asynchronous flow.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function saveScore(username, score) {
    // 1. Save the score
    const { error } = await supabase
        .from('snake_scores')
        .insert([{ username: username, score: score }]);

    if (error) {
        console.error('Error uploading data:', error);
        alert('Connection error with the central server.');
        return;
    }
    
    // 2. Immediately after, update the leaderboard visually
    fetchLeaderboard();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;3-fetching-the-ranking-select-&quot;&gt;3. Fetching the Ranking (Select)&lt;/h4&gt;
&lt;p&gt;We retrieve the &lt;strong&gt;Top 20&lt;/strong&gt; sorted by score in descending order to show who rules the server.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function fetchLeaderboard() {
    const { data: scores, error } = await supabase
        .from('snake_scores')
        .select('username, score')
        .order('score', { ascending: false }) // Descending order
        .limit(20);                           // Only the top 20

    if (error) console.error('Error fetching leaderboard:', error);
    
    // Render in the DOM
    const list = document.getElementById('leaderboard');
    list.innerHTML = '&amp;lt;h3 class=&quot;text-cyan-400 border-b border-cyan-500 mb-2 font-bold&quot;&amp;gt;TOP HACKERS&amp;lt;/h3&amp;gt;';
    
    scores.forEach((entry, index) =&amp;gt; {
        // Highlight top 3 with different colors if you want
        const colorClass = index &amp;lt; 3 ? 'text-yellow-400' : 'text-gray-300';
        
        list.innerHTML += `
            &amp;lt;div class=&quot;flex justify-between py-1 border-b border-gray-800&quot;&amp;gt;
                &amp;lt;span class=&quot;${colorClass}&quot;&amp;gt;#${index + 1} ${entry.username}&amp;lt;/span&amp;gt;
                &amp;lt;span class=&quot;text-cyan-300 font-mono&quot;&amp;gt;${entry.score}&amp;lt;/span&amp;gt;
            &amp;lt;/div&amp;gt;`;
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;bonus-realtime-&quot;&gt;Bonus: Realtime ⚡&lt;/h4&gt;
&lt;p&gt;Do you want the ranking to update on &lt;strong&gt;everyone's&lt;/strong&gt; screen if someone breaks the record at that precise moment? Supabase makes this trivial with its subscriptions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// Subscribe to changes in the 'snake_scores' table
supabase
  .channel('public:snake_scores')
  .on('postgres_changes', 
      { event: 'INSERT', schema: 'public', table: 'snake_scores' }, 
      (payload) =&amp;gt; {
        console.log('New record detected on the network!', payload);
        fetchLeaderboard(); // Automatically refresh the list for everyone
      }
  )
  .subscribe();
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-final-result&quot;&gt;The Final Result&lt;/h3&gt;
&lt;p&gt;By putting it all together, you get a fluid and highly competitive user experience:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The user plays frantically dodging neon &quot;Firewalls&quot;.&lt;/li&gt;
&lt;li&gt;Upon crashing, the game stops with a visual &quot;glitch&quot; effect.&lt;/li&gt;
&lt;li&gt;They enter their name (e.g., &quot;Neo&quot;).&lt;/li&gt;
&lt;li&gt;Upon clicking send, &lt;strong&gt;in milliseconds&lt;/strong&gt;, their name appears on the leaderboard.&lt;/li&gt;
&lt;li&gt;If their friend is playing on another mobile at the same time, they will see &quot;Neo's&quot; name appear on their screen without reloading the page.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And once created... all that remains is to enjoy and play &lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/games/snake/&quot;&gt;Datalaria Snake Game&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;We have moved from a simple static canvas to a &lt;strong&gt;Fullstack&lt;/strong&gt; application in real-time without touching a traditional backend server (Node.js, PHP, Python) or managing infrastructure. The combination of creativity in game development with the power of BaaS tools like Supabase opens a world of possibilities for frontend developers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The next step?&lt;/strong&gt; I invite you to clone the project and improve it: add GitHub authentication to avoid duplicate names, create snake skins that unlock with high scores, or implement a multiplayer mode using Supabase presence channels.&lt;/p&gt;
&lt;p&gt;The code is yours! &lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Did you like this tutorial? Share it on your networks with your High Scores and don't forget to check the official Supabase documentation to dive deeper.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/12990/from-zero-hero-create-cyberpunk-snake-game-with-real-time-ranking-using-supabase-vanilla</guid>
<pubDate>Tue, 17 Mar 2026 06:55:03 +0000</pubDate>
</item>
<item>
<title>Building Bridges with Code: How We Created a Multilingual Audio Learning Platform</title>
<link>https://coderlegion.com/10981/building-bridges-with-code-how-we-created-a-multilingual-audio-learning-platform</link>
<description>&lt;p&gt;Subtitle: A technical journey inside the Interim Federal Education Council’s mission to digitize and localize Civic Education using Next.js and Supabase.&lt;/p&gt;
&lt;p&gt;Introduction: The Urgent Need for Inclusive Education&lt;br&gt;
In the diverse educational landscape of Myanmar, language is often the biggest barrier to learning. For the Interim Federal Education Council (IFEC), ensuring Grade 6-9 students have access to vital Civic Education curriculum meant going beyond the standard English and Myanmar textbooks.&lt;br&gt;
We faced a massive challenge: How do we take a standardized curriculum and transform it into accessible, engaging audio lessons for students speaking Kachin, Karenni, Karen, Chin, Mon, Rakhine, and Shan?&lt;br&gt;
We needed a digital hub—a place where language experts could easily contribute translations, and students could easily listen. We didn't have a massive engineering team; we needed speed, reliability, and ease of use.&lt;br&gt;
This is the story of how we built the IFEC Civic Education Platform from a simple idea into a live, full-stack web application.&lt;/p&gt;
&lt;p&gt;The Solution: The &quot;Podcast&quot; Approach &amp;amp; Tech Stack&lt;br&gt;
We decided on an audio-first approach. &quot;Podcasts&quot; are accessible, low-bandwidth, and easy to consume on mobile devices.&lt;br&gt;
To build the platform, we chose a modern, powerful technology stack geared toward rapid development:&lt;br&gt;
Frontend: Next.js (React): For building a fast, interactive user interface that works well on phones and laptops.&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=8980633286104272670&quot; alt=&quot;Figure 1: Next.js&quot;&gt;&lt;/p&gt;
&lt;p&gt;Backend &amp;amp; Storage: Supabase: An open-source Firebase alternative. It provided us with a real-time database to store lesson details and secure cloud storage for large MP3 and PDF files, all without needing to manage our own servers.&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=10870940109820675685&quot; alt=&quot;Figure 2: Supabase backend&quot;&gt;&lt;/p&gt;
&lt;p&gt;Phase 1: Laying the Foundation (The Backend)&lt;br&gt;
Before writing a single line of frontend code, we had to model our data in Supabase. We needed two critical components:&lt;br&gt;
File Storage: We created a public storage bucket named civic_podcasts to house the MP3 audio files and optional PDF scripts.&lt;br&gt;
The Database: We set up a lessons table to track the metadata for every file uploaded. Key fields included:&lt;br&gt;
title (Text)&lt;br&gt;
grade_level (Number, 6-9)&lt;br&gt;
language (Text)&lt;br&gt;
audio_url &amp;amp; pdf_url (Links to the files in storage)&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=17278279860380453276&quot; alt=&quot;Figure 3: Supabase storage&quot;&gt;&lt;br&gt;
Lesson Learned: A solid data structure at the start makes the frontend development much easier later on.&lt;/p&gt;
&lt;p&gt;Phase 2: Building the &quot;Expert Portal&quot; (The Upload Flow)&lt;br&gt;
Our language experts are educators, not engineers. The upload process needed to be incredibly simple.&lt;br&gt;
We built a secure /upload page in Next.js. It featured a straightforward form where experts select the grade and language, attach their files, and enter a shared Admin Access Code for security.&lt;br&gt;
The Technical Hurdles: Building this wasn't without challenges. We encountered two major hurdles that are common in full-stack development:&lt;br&gt;
Hurdle 1: The &quot;Database Lock&quot; (Row-Level Security): Initially, all uploads failed. Supabase is secure by default, meaning it blocks all database insertions unless explicitly allowed. We had to write specific SQL Policies to &quot;unlock&quot; the database for inserts, relying on our frontend access code for security.&lt;br&gt;
Hurdle 2: File Conflicts: If an expert tried to re-upload a corrected version of a lesson, the storage system would reject it because a file with that name already existed. We solved this by implementing an upsert: true (update or insert) command in our code, forcing the system to overwrite old files with new versions.&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=16590351781982395670&quot; alt=&quot;Figure 4: Supabase SQL editor&quot;&gt;&lt;/p&gt;
&lt;p&gt;Phase 3: The Student Experience (UI/UX Transformation)&lt;br&gt;
Once we could get data in, we needed to get it out in an engaging way.&lt;br&gt;
Our initial student view was a basic list of links. It worked, but it wasn't inspiring. We realized that for grade school students, the design needed to be vibrant and intuitive.&lt;br&gt;
We transformed the interface into a modern, card-based layout. We used color-coding to create visual distinction: blue for Grade 6, teal for Grade 7, orange for Grade 8, and purple for Grade 9.&lt;br&gt;
We also added crucial functionality. We realized audio wasn't always enough; some students needed to read along. We updated the upload portal to accept PDF transcripts and added a prominent &quot; Read Script&quot; button to the student-facing cards.&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=10299412811574795480&quot; alt=&quot;Figure 5: The Student Experience (UI/UX Transformation)&quot;&gt;&lt;/p&gt;
&lt;p&gt;Phase 4: Distribution and Real-World Use&lt;br&gt;
A great platform is useless if teachers can't share it. In many classrooms in Myanmar, sharing long URLs is impractical.&lt;br&gt;
To solve the &quot;last mile&quot; distribution problem, we integrated a dynamic QR code directly onto the platform's homepage. A teacher can now simply open their laptop, scroll down, and have an entire classroom scan the screen to instantly load the lessons on their mobile devices.&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=786259695231999034&quot; alt=&quot;Figure 6: QR Code&quot;&gt;&lt;/p&gt;
&lt;p&gt;Conclusion: From Concept to Classroom&lt;br&gt;
In a very short time, we moved from an abstract requirement to a deployed, multilingual educational tool.&lt;br&gt;
Today, the platform stands ready. Language administrators have a secure dashboard to manage content, and students have a colorful, engaging hub to access vital Civic Education in their mother tongue.&lt;br&gt;
This project proved that with the right modern tools like Next.js and Supabase, complex logistical challenges in education can be solved efficiently, creating real impact where it’s needed most.&lt;br&gt;
[Link to the live IFEC platform: https://ifec-civic-platform.vercel.app/]&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/10981/building-bridges-with-code-how-we-created-a-multilingual-audio-learning-platform</guid>
<pubDate>Tue, 03 Feb 2026 12:16:07 +0000</pubDate>
</item>
<item>
<title>What is Supabase and Supabase Edge Functions? What's the main use?</title>
<link>https://coderlegion.com/4397/what-is-supabase-and-supabase-edge-functions-whats-the-main-use</link>
<description>&lt;p&gt;&lt;strong&gt;Supabase&lt;/strong&gt; is an open-source backend-as-a-service (BaaS) platform that helps developers build secure, scalable applications quickly. It provides a suite of backend tools—similar to Firebase—but built on open standards like PostgreSQL.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;what-supabase-offers-core-features-&quot;&gt;What Supabase Offers (Core Features):&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL Database&lt;/strong&gt; – A powerful, scalable, open-source SQL database.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auth&lt;/strong&gt; – User authentication and authorization with support for social logins (Google, GitHub, etc.).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Realtime&lt;/strong&gt; – Realtime updates using websockets (e.g., when database rows change).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Storage&lt;/strong&gt; – File storage (images, videos, docs) with access control.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edge Functions&lt;/strong&gt; – Serverless functions that run close to the user (more below).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;APIs&lt;/strong&gt; – Auto-generated REST and GraphQL APIs for your database.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dashboard&lt;/strong&gt; – A web-based GUI for managing your backend.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h4 id=&quot;-supabase-edge-functions&quot;&gt;⚡ Supabase Edge Functions&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Supabase Edge Functions&lt;/strong&gt; are serverless functions that you write in JavaScript or TypeScript and deploy to run on &lt;strong&gt;Deno&lt;/strong&gt; (a secure runtime similar to Node.js but more modern).&lt;/p&gt;
&lt;h5 id=&quot;main-use-of-edge-functions-&quot;&gt;Main Use of Edge Functions:&lt;/h5&gt;
&lt;p&gt;They are designed for handling backend logic that shouldn't live directly in the client or the database.&lt;/p&gt;
&lt;h5 id=&quot;-use-cases-include-&quot;&gt;✅ Use Cases Include:&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;Custom API endpoints (e.g., payment processing, webhook handling)&lt;/li&gt;
&lt;li&gt;Middleware logic (e.g., verifying tokens, checking user roles)&lt;/li&gt;
&lt;li&gt;Connecting to third-party APIs (e.g., Stripe, Twilio, etc.)&lt;/li&gt;
&lt;li&gt;Running logic on scheduled intervals (e.g., cron jobs)&lt;/li&gt;
&lt;li&gt;Doing something that SQL triggers can’t handle&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;why-use-supabase-and-edge-functions-together-&quot;&gt;Why Use Supabase and Edge Functions Together?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Use &lt;strong&gt;Supabase&lt;/strong&gt; to handle your core app data and auth.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;Edge Functions&lt;/strong&gt; to handle custom logic securely and efficiently.&lt;/li&gt;
&lt;li&gt;Keep everything &lt;strong&gt;in one platform&lt;/strong&gt;, tightly integrated.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;quick comparison&lt;/strong&gt; and a &lt;strong&gt;simple example&lt;/strong&gt; to help you understand how &lt;strong&gt;Supabase Edge Functions&lt;/strong&gt; work—especially compared to &lt;strong&gt;Firebase Cloud Functions&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;comparison-supabase-edge-functions-vs-firebase-functions&quot;&gt;Comparison: Supabase Edge Functions vs Firebase Functions&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Feature                    &lt;/th&gt;&lt;th&gt;Supabase Edge Functions               &lt;/th&gt;&lt;th&gt;Firebase Cloud Functions&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Runtime                    &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Deno&lt;/strong&gt; (TypeScript-native)          &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Node.js&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Deployment Time            &lt;/td&gt;&lt;td&gt;Fast (\~seconds)                      &lt;/td&gt;&lt;td&gt;Slower (\~minutes)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cold Start                 &lt;/td&gt;&lt;td&gt;Very Low (Deno edge runtime)          &lt;/td&gt;&lt;td&gt;Can be noticeable&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Region                     &lt;/td&gt;&lt;td&gt;Edge (closer to user, low latency)    &lt;/td&gt;&lt;td&gt;Usually centralized&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Language Support           &lt;/td&gt;&lt;td&gt;TypeScript, JavaScript                &lt;/td&gt;&lt;td&gt;JavaScript, TypeScript&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tied to Backend (DB, Auth) &lt;/td&gt;&lt;td&gt;Yes – deeply integrated with Supabase &lt;/td&gt;&lt;td&gt;Yes – integrated with Firebase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hosting Model              &lt;/td&gt;&lt;td&gt;Serverless, edge-deployed             &lt;/td&gt;&lt;td&gt;Serverless, central&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-simple-use-case-example&quot;&gt;✅ Simple Use Case Example&lt;/h3&gt;
&lt;h4 id=&quot;scenario-&quot;&gt;Scenario:&lt;/h4&gt;
&lt;p&gt;You want to verify that a user has a valid subscription before letting them access premium content.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;1-in-firebase-&quot;&gt;1. &lt;strong&gt;In Firebase:&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;You’d write a cloud function like this (Node.js):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;exports.checkSubscription = functions.https.onRequest((req, res) =&amp;gt; {
  const userId = req.body.userId;
  // Call Stripe or check Firestore
  // Return access status
});
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;2-in-supabase-edge-function-&quot;&gt;2. &lt;strong&gt;In Supabase (Edge Function):&lt;/strong&gt;&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// supabase/functions/check-subscription/index.ts

import { serve } from &quot;https://deno.land/std/http/server.ts&quot;;

serve(async (req) =&amp;gt; {
  const { userId } = await req.json();

  // Call Stripe API, or query your Supabase DB
  const isActive = true; // pretend check

  return new Response(JSON.stringify({
    access: isActive ? &quot;granted&quot; : &quot;denied&quot;
  }), {
    headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;-bonus-&quot;&gt;➕ Bonus:&lt;/h4&gt;
&lt;p&gt;You can secure this function by requiring Supabase Auth tokens and decoding them right inside the function.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-to-deploy-cli-&quot;&gt;How to Deploy (CLI):&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;supabase functions deploy check-subscription
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then call it from your frontend:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const res = await fetch(&quot;/functions/v1/check-subscription&quot;, {
  method: &quot;POST&quot;,
  headers: { &quot;Authorization&quot;: `Bearer ${userToken}` },
  body: JSON.stringify({ userId }),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/4397/what-is-supabase-and-supabase-edge-functions-whats-the-main-use</guid>
<pubDate>Sat, 02 Aug 2025 20:48:12 +0000</pubDate>
</item>
<item>
<title>How I Survived My First Full stack App with Next.js</title>
<link>https://coderlegion.com/4131/how-i-survived-my-first-full-stack-app-with-next-js</link>
<description>&lt;p&gt;The story begins—as it always does—with a tutorial.&lt;/p&gt;
&lt;p&gt;I was watching yet another video and thought: &lt;em&gt;“Let’s build a full stack app.”&lt;/em&gt; I already had a background in React, and I’d made a few full stack projects before. So this time, I chose &lt;strong&gt;Next.js&lt;/strong&gt;. It looked easy, especially with the new App Router.&lt;/p&gt;
&lt;p&gt;Why Next.js? Because it’s React at the front, backend APIs included, and server actions baked right in. No need for separate Express servers or wiring up extra routers. Also, the assumption was simple: &lt;em&gt;everything is a server component unless you slap a &lt;code&gt;&quot;use client&quot;&lt;/code&gt; on top.&lt;/em&gt; I was in.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-why-i-fell-in-love-with-the-app-router&quot;&gt;✨ Why I Fell in Love with the App Router&lt;/h3&gt;
&lt;p&gt;One of the best things about Next.js is the App Router. You don’t need to install React Router and define routes in &lt;code&gt;App.jsx&lt;/code&gt;. Just drop a file in the &lt;code&gt;/app&lt;/code&gt; folder, and boom—it’s a route. It's simple. It’s fast. It works.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;first-battle-the-ui-fear&quot;&gt;First Battle: The UI Fear&lt;/h3&gt;
&lt;p&gt;Here’s my harsh truth—I suck at design.&lt;/p&gt;
&lt;p&gt;My pages looked &lt;em&gt;ugly&lt;/em&gt;. And that really messes with you. It triggers imposter syndrome. I’d open VS Code, freeze, and think: &lt;em&gt;What do I even build here?&lt;/em&gt; The spiral begins: “Am I even cut out for frontend dev?”&lt;/p&gt;
&lt;p&gt;Sound familiar? Been there. This time, I didn’t let it win.&lt;/p&gt;
&lt;h4 id=&quot;here-s-how-i-beat-the-block-&quot;&gt;Here's how I beat the block:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Took breaks&lt;/strong&gt; when stuck.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drew wireframes&lt;/strong&gt;, even if they were ugly. Just enough to give me direction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Used inspiration from other websites&lt;/strong&gt;. Seriously, 90% of the internet uses the same layout tricks. Why not learn from them?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tools like Google Stitch, v0.dev, and Bolt&lt;/strong&gt; helped generate page ideas and inspiration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Any progress is better than no progress. Remember that.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-server-actions&quot;&gt;❤️ Server Actions&lt;/h3&gt;
&lt;p&gt;God bless Server Actions.&lt;/p&gt;
&lt;p&gt;No more creating an API route or writing fetch calls and handling them manually. Need to read from the server? Just keep the page component as a server component, use a Server Action to grab the data, and pass it down to your client components. Done.&lt;/p&gt;
&lt;p&gt;You can &lt;strong&gt;use server actions inside client components too&lt;/strong&gt;, but there are caveats. I won’t pretend I know them all—this project was me figuring things out on the fly. So I won’t preach, but here’s what worked for me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep data fetching in server components.&lt;/li&gt;
&lt;li&gt;Use server actions to mutate or fetch data.&lt;/li&gt;
&lt;li&gt;If using forms, yes—you can still call server actions from client components.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-i-wrote-ugly-code-fast&quot;&gt;How I Wrote Ugly Code—Fast&lt;/h3&gt;
&lt;p&gt;Not gonna lie. I was a messy developer in the beginning.&lt;/p&gt;
&lt;p&gt;Every time I made a new page/component, I didn't care about reusability. Things got chaotic fast.&lt;/p&gt;
&lt;p&gt;But I followed &lt;strong&gt;one rule&lt;/strong&gt;: put every component inside a well-named folder. For example: &lt;code&gt;/components/dashboard/Table.tsx&lt;/code&gt;. Later, when I needed it somewhere else, I refactored it—added props, defined interfaces, added type safety—and made it reusable.&lt;/p&gt;
&lt;p&gt;So... am I a great React developer? Maybe not.&lt;/p&gt;
&lt;p&gt;Am I resourceful and fast? Yes.&lt;/p&gt;
&lt;p&gt;Best case: smart developer.&lt;/p&gt;
&lt;p&gt;Worst case: functional chaos.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-email-saga&quot;&gt;The Email Saga&lt;/h3&gt;
&lt;p&gt;Now I understand why dev’s complain about &lt;strong&gt;bad DX (Developer Experience)&lt;/strong&gt; on Twitter.&lt;/p&gt;
&lt;p&gt;Until now, I had a chill life—&lt;code&gt;React + Vite&lt;/code&gt;, everything worked like magic. But for this project, I needed to &lt;strong&gt;send emails&lt;/strong&gt; inviting users to give feedback.&lt;/p&gt;
&lt;p&gt;I initially tried &lt;strong&gt;Mailjet&lt;/strong&gt; — looked promising, solid docs, decent limits. But then... &lt;strong&gt;everything broke&lt;/strong&gt; after sending just 5 emails.&lt;/p&gt;
&lt;p&gt;I got hit with a &lt;strong&gt;&quot;temporary restriction&quot;&lt;/strong&gt; and no real explanation. Just a vague message and complete silence. I waited &lt;strong&gt;three days&lt;/strong&gt;, hoping the ban would lift. Spoiler: it didn’t.&lt;/p&gt;
&lt;p&gt;That’s when I realized how important good &lt;strong&gt;developer experience&lt;/strong&gt; really is.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If I ever build a dev tool, I’m writing great docs, clear errors, and probably a fun YouTube guide.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So, I ditched Mailjet and moved to &lt;strong&gt;Resend&lt;/strong&gt; — and this time, everything &lt;em&gt;just worked&lt;/em&gt;. Fast setup, friendly DX, and no cryptic errors.&lt;/p&gt;
&lt;p&gt;❤️ Huge shoutout to Resend.&lt;/p&gt;
&lt;p&gt;???? No hard feelings, Mailjet — but that experience wasn’t it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-broken-database-horror&quot;&gt;The Broken Database Horror&lt;/h3&gt;
&lt;p&gt;After surviving all that, I wanted to &lt;strong&gt;brag on LinkedIn&lt;/strong&gt;: “Hey look! I integrated payments!” Even if they were test payments—still, it’s progress.&lt;/p&gt;
&lt;p&gt;But the horror unfolded quickly.&lt;/p&gt;
&lt;p&gt;Understanding how payments work end-to-end—from initiation to confirmation—is &lt;strong&gt;critical&lt;/strong&gt;. If you don’t, you’ll keep breaking your schema with every little change.&lt;/p&gt;
&lt;h4 id=&quot;what-i-learned-&quot;&gt;What I learned:&lt;/h4&gt;
&lt;p&gt;If you're dealing with &lt;strong&gt;third-party APIs or external systems&lt;/strong&gt;, always:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Track their &lt;strong&gt;status&lt;/strong&gt; (&lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;failure&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;enum&lt;/code&gt; fields to reflect the state&lt;/li&gt;
&lt;li&gt;Expect that some parts of the flow are &lt;strong&gt;out of your control&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Know what you're storing and when. Or prepare to suffer. And yes, it &lt;em&gt;will&lt;/em&gt; become a dev horror story.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;linting-typescript-woes&quot;&gt;Linting + TypeScript Woes&lt;/h3&gt;
&lt;p&gt;I used to think &lt;strong&gt;linting&lt;/strong&gt; was just that one VS Code extension that makes code pretty.&lt;/p&gt;
&lt;p&gt;Then I ran &lt;code&gt;npm run build&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Boom. &lt;strong&gt;10,000+ lines of errors.&lt;/strong&gt; Why? It tried to lint the auto-generated code too.&lt;/p&gt;
&lt;p&gt;So yeah, I had to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fix unused variables&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;any&lt;/code&gt; types&lt;/li&gt;
&lt;li&gt;Update configs to ignore generated folders&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Lesson learned: don't skip linting. TypeScript isn’t just for safety—it’s a build-saver.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;deployment-amp-production-nightmares&quot;&gt;Deployment &amp;amp; Production Nightmares&lt;/h3&gt;
&lt;p&gt;I’d spent a month building this project. Now it was time to ship. Resume-ready, you know?&lt;/p&gt;
&lt;p&gt;I used &lt;strong&gt;Vercel&lt;/strong&gt;. Guess what?&lt;/p&gt;
&lt;p&gt;Vercel &lt;strong&gt;kept trying to render server pages as static&lt;/strong&gt;. My build kept failing.&lt;/p&gt;
&lt;p&gt;Turns out: Vercel will &lt;em&gt;try to statically generate everything&lt;/em&gt; unless you tell it not to.&lt;/p&gt;
&lt;h4 id=&quot;solution-&quot;&gt;Solution:&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export const dynamic = &quot;force-dynamic&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Boom. Now it knows your page is meant to run &lt;strong&gt;server-side&lt;/strong&gt; only.&lt;/p&gt;
&lt;p&gt;Oh, and &lt;strong&gt;Supabase&lt;/strong&gt;—my free DB. Connecting it was hell.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;There’s a &lt;strong&gt;pooled connection&lt;/strong&gt; (for normal ops)&lt;/li&gt;
&lt;li&gt;And a &lt;strong&gt;direct connection&lt;/strong&gt; (for migrations)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But Prisma didn’t play well with pooled URLs. It caused weird, unexpected issues.&lt;/p&gt;
&lt;h4 id=&quot;fix-&quot;&gt;Fix:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Use &lt;strong&gt;Supabase’s &quot;stable connection string&quot;&lt;/strong&gt; for local + migrations&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;Prisma Accelerate&lt;/strong&gt; in production with the correct URL&lt;/li&gt;
&lt;li&gt;Add the Accelerate URL to your &lt;code&gt;.env.production&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After that, things worked. Kinda.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;You may not have learned any &lt;em&gt;technical&lt;/em&gt; tricks from this post. But that’s the point.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Things will break.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Abstractions will leak.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You’ll be stuck. Frustrated. Confused.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;But you’re a builder. And builders show up.&lt;/p&gt;
&lt;p&gt;That’s what matters.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-takeaway&quot;&gt;❤️ Takeaway&lt;/h3&gt;
&lt;p&gt;Everyone can teach you code. I want to teach you a mindset.&lt;/p&gt;
&lt;p&gt;If you remember just two things from this post:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Break things.&lt;/strong&gt; That’s how you learn.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Draw wireframes.&lt;/strong&gt; Ugly ones. It gives you momentum.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That’s it. You’ll be 10x faster than me if you just start.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;follow-my-journey&quot;&gt;Follow My Journey&lt;/h3&gt;
&lt;p&gt;I write about my dev rants, lessons, and experiments—sometimes messy, sometimes insightful. If this resonated, follow me. I don’t just rant—I teach too.&lt;/p&gt;
&lt;p&gt;Next up: how I broke and optimized my backend.&lt;/p&gt;
&lt;p&gt;Till then,&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Break. Fix. Weep. Shine.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;— Love and peace,&lt;/p&gt;
&lt;p&gt;Your fellow dev&lt;/p&gt;
&lt;h3 id=&quot;farewell-amp-little-sendoff-gifts-for-curious-minds&quot;&gt;Farewell &amp;amp; Little Sendoff Gifts for Curious Minds&lt;/h3&gt;
&lt;p&gt;Thanks for sticking around! If you're curious to poke around or steal some ideas (I won't tell), here are a few goodies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Live App:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://feedback.swaroop101.me/&quot;&gt;Create Next App&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GitHub Repo:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/PRASHANTSWAROOP001/feedback-nextjs-app&quot;&gt;https://github.com/PRASHANTSWAROOP001/feedback-nextjs-app&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wireframes (Chaos Included):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://raw.githubusercontent.com/PRASHANTSWAROOP001/contact-page/refs/heads/main/wireframe2.jpg&quot;&gt;https://raw.githubusercontent.com/PRASHANTSWAROOP001/contact-page/refs/heads/main/wireframe2.jpg&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/PRASHANTSWAROOP001/contact-page/blob/main/wireframe1.jpg?raw=true&quot;&gt;https://github.com/PRASHANTSWAROOP001/contact-page/blob/main/wireframe1.jpg?raw=true&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>React</category>
<guid isPermaLink="true">https://coderlegion.com/4131/how-i-survived-my-first-full-stack-app-with-next-js</guid>
<pubDate>Fri, 18 Jul 2025 12:38:28 +0000</pubDate>
</item>
<item>
<title>Building Scalable React Native Apps with Nucleux and Supabase</title>
<link>https://coderlegion.com/4010/building-scalable-react-native-apps-with-nucleux-and-supabase</link>
<description>&lt;p&gt;Combining Supabase's powerful backend services with Nucleux's atomic state management creates a clean, scalable architecture for React Native applications. This guide shows you how to structure authentication and API services using class-based stores that naturally organize your application logic.&lt;/p&gt;
&lt;h3 id=&quot;the-architecture-pattern&quot;&gt;The Architecture Pattern&lt;/h3&gt;
&lt;p&gt;We'll build a three-layer architecture that separates concerns cleanly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ApiService&lt;/code&gt;&lt;/strong&gt;: Handles Supabase client configuration and lifecycle&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SessionStore&lt;/code&gt;&lt;/strong&gt;: Manages authentication state and user sessions  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Auth&lt;/code&gt; Component&lt;/strong&gt;: Provides the user interface for authentication&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This pattern leverages Nucleux's dependency injection to create a clean separation where each layer has a single responsibility.&lt;/p&gt;
&lt;h3 id=&quot;setting-up-the-api-service&quot;&gt;Setting Up the API Service&lt;/h3&gt;
&lt;p&gt;First, create a centralized API service that manages the Supabase client:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/services/ApiService.ts
import AsyncStorage from &quot;@react-native-async-storage/async-storage&quot;;
import { createClient, SupabaseClient } from &quot;@supabase/supabase-js&quot;;
import { Store } from &quot;nucleux&quot;;
import { AppState, NativeEventSubscription, Platform } from &quot;react-native&quot;;
import &quot;react-native-url-polyfill/auto&quot;;

if (
  process.env.EXPO_PUBLIC_SUPABASE_URL === undefined ||
  process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY === undefined
) {
  throw new Error(&quot;Missing critical environment variables&quot;);
}

class ApiService extends Store {
  public apiClient: SupabaseClient;
  private appStateSubscription: NativeEventSubscription;

  constructor() {
    super();

    this.apiClient = createClient(
      process.env.EXPO_PUBLIC_SUPABASE_URL || &quot;&quot;,
      process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || &quot;&quot;,
      {
        auth: {
          storage: AsyncStorage,
          autoRefreshToken: true,
          persistSession: true,
          detectSessionInUrl: false,
        },
      }
    );

    this.appStateSubscription = AppState.addEventListener(&quot;change&quot;, (state) =&amp;gt; {
      if (state === &quot;active&quot;) {
        this.apiClient.auth.startAutoRefresh();
      } else {
        this.apiClient.auth.stopAutoRefresh();
      }
    });
  }

  destroy() {
    this.appStateSubscription.remove();
    super.destroy();
  }
}

export default ApiService;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key Benefits:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Centralized Configuration&lt;/strong&gt;: Single place to manage Supabase setup&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lifecycle Management&lt;/strong&gt;: Properly manages token refresh based on app state&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;creating-the-session-store&quot;&gt;Creating the Session Store&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;SessionStore&lt;/code&gt; handles all authentication logic while staying completely decoupled from UI concerns:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/stores/SessionStore.ts
import ApiService from &quot;@/services/ApiService&quot;;
import { Session, Subscription } from &quot;@supabase/supabase-js&quot;;
import { Store } from &quot;nucleux&quot;;

class SessionStore extends Store {
  public session = this.atom&amp;lt;Session | null&amp;gt;(null);
  public initialized = this.atom&amp;lt;boolean&amp;gt;(false);

  private apiService = this.inject(ApiService);
  private authSubscription: Subscription;

  constructor() {
    super();
    
    this.getSession();
    
    this.authSubscription = this.apiService.apiClient.auth.onAuthStateChange(
      (_, session) =&amp;gt; {
        this.setSession(session);
      }
    ).data.subscription;
  }

  private async getSession() {
    try {
      const { data: { session } } = await this.apiService.apiClient.auth.getSession();
      this.setSession(session);
    } finally {
      this.initialized.value = true;
    }
  }

  private setSession(session: Session | null) {
    this.session.value = session;
  }

  public async signIn(email: string, password: string) {
    return this.apiService.apiClient.auth.signInWithPassword({
      email,
      password,
    });
  }

  public async signOut() {
    return this.apiService.apiClient.auth.signOut();
  }

  destroy() {
    this.authSubscription.unsubscribe();
    super.destroy();
  }
}

export default SessionStore;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Architecture Highlights:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dependency Injection&lt;/strong&gt;: &lt;code&gt;this.inject(ApiService)&lt;/code&gt; automatically provides the API client&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Atomic State&lt;/strong&gt;: Only components using &lt;code&gt;session&lt;/code&gt; or &lt;code&gt;initialized&lt;/code&gt; re-render when these values change&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Event Handling&lt;/strong&gt;: Supabase auth changes automatically update the atomic state&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clean API&lt;/strong&gt;: Simple methods for sign in/out that any component can use&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;building-the-auth-component&quot;&gt;Building the Auth Component&lt;/h3&gt;
&lt;p&gt;Finally, create a simple authentication component that consumes the session store:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/Auth.tsx
import SessionStore from &quot;@/stores/SessionStore&quot;;
import { useStore, useValue } from &quot;nucleux&quot;;
import React, { useState } from &quot;react&quot;;
import { Alert, StyleSheet, Text, TextInput, TouchableOpacity, View } from &quot;react-native&quot;;

export default function Auth() {
  const sessionStore = useStore(SessionStore);
  const session = useValue(sessionStore.session);
  const initialized = useValue(sessionStore.initialized);

  const [email, setEmail] = useState(&quot;&quot;);
  const [password, setPassword] = useState(&quot;&quot;);
  const [loading, setLoading] = useState(false);

  const handleSignIn = async () =&amp;gt; {
    if (!email || !password) {
      Alert.alert(&quot;Error&quot;, &quot;Please fill in all fields&quot;);
      return;
    }

    setLoading(true);
    try {
      const { error } = await sessionStore.signIn(email, password);
      if (error) Alert.alert(&quot;Error&quot;, error.message);
    } finally {
      setLoading(false);
    }
  };

  const handleSignOut = async () =&amp;gt; {
    const { error } = await sessionStore.signOut();
    if (error) Alert.alert(&quot;Error&quot;, error.message);
  };

  if (!initialized) {
    return &amp;lt;Text&amp;gt;Loading...&amp;lt;/Text&amp;gt;;
  }

  if (session) {
    return (
      &amp;lt;View style={styles.container}&amp;gt;
        &amp;lt;Text style={styles.title}&amp;gt;Welcome back!&amp;lt;/Text&amp;gt;
        &amp;lt;Text&amp;gt;Signed in as: {session.user.email}&amp;lt;/Text&amp;gt;
        &amp;lt;TouchableOpacity style={styles.button} onPress={handleSignOut}&amp;gt;
          &amp;lt;Text style={styles.buttonText}&amp;gt;Sign Out&amp;lt;/Text&amp;gt;
        &amp;lt;/TouchableOpacity&amp;gt;
      &amp;lt;/View&amp;gt;
    );
  }

  return (
    &amp;lt;View style={styles.container}&amp;gt;
      &amp;lt;Text style={styles.title}&amp;gt;Sign In&amp;lt;/Text&amp;gt;
      
      &amp;lt;TextInput
        style={styles.input}
        placeholder=&quot;Email&quot;
        value={email}
        onChangeText={setEmail}
        autoCapitalize=&quot;none&quot;
        keyboardType=&quot;email-address&quot;
      /&amp;gt;
      
      &amp;lt;TextInput
        style={styles.input}
        placeholder=&quot;Password&quot;
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      /&amp;gt;
      
      &amp;lt;TouchableOpacity 
        style={[styles.button, loading &amp;amp;&amp;amp; styles.buttonDisabled]} 
        onPress={handleSignIn}
        disabled={loading}
      &amp;gt;
        &amp;lt;Text style={styles.buttonText}&amp;gt;
          {loading ? &quot;Signing In...&quot; : &quot;Sign In&quot;}
        &amp;lt;/Text&amp;gt;
      &amp;lt;/TouchableOpacity&amp;gt;
    &amp;lt;/View&amp;gt;
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, justifyContent: &quot;center&quot; },
  title: { fontSize: 24, fontWeight: &quot;bold&quot;, marginBottom: 20, textAlign: &quot;center&quot; },
  input: { borderWidth: 1, borderColor: &quot;#ddd&quot;, padding: 12, marginBottom: 10, borderRadius: 5 },
  button: { backgroundColor: &quot;#007AFF&quot;, padding: 12, borderRadius: 5, marginTop: 10 },
  buttonDisabled: { opacity: 0.5 },
  buttonText: { color: &quot;white&quot;, textAlign: &quot;center&quot;, fontWeight: &quot;bold&quot; },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;why-this-architecture-works&quot;&gt;Why This Architecture Works&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Clear Separation of Concerns&lt;/strong&gt;&lt;br&gt;
Each layer has a single responsibility, making the code easier to test and maintain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Automatic Reactivity&lt;/strong&gt;&lt;br&gt;
Components automatically re-render when authentication state changes, without manual subscription management.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Reusable Business Logic&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;SessionStore&lt;/code&gt; can be used across multiple components without duplicating authentication logic.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Type Safety&lt;/strong&gt;&lt;br&gt;
TypeScript ensures you catch authentication-related bugs at compile time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Easy Testing&lt;/strong&gt;&lt;br&gt;
Each store can be unit tested independently of React components.&lt;/p&gt;
&lt;h3 id=&quot;the-result&quot;&gt;The Result&lt;/h3&gt;
&lt;p&gt;This architecture gives you a robust foundation for React Native apps that need authentication. The &lt;code&gt;SessionStore&lt;/code&gt; becomes the single source of truth for authentication state, while the component layer focuses purely on user interface concerns.&lt;/p&gt;
&lt;p&gt;Want to explore more patterns? Check out the complete &lt;a rel=&quot;nofollow&quot; href=&quot;https://nucleux.dev&quot;&gt;Nucleux documentation&lt;/a&gt; and see how this same approach scales to complex applications with multiple data stores and API services.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Ready to build your next React Native app? Try combining &lt;a rel=&quot;nofollow&quot; href=&quot;https://nucleux.dev&quot;&gt;Nucleux&lt;/a&gt; with &lt;a rel=&quot;nofollow&quot; href=&quot;https://supabase.com&quot;&gt;Supabase&lt;/a&gt; for a powerful, type-safe development experience.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Mobile Dev</category>
<guid isPermaLink="true">https://coderlegion.com/4010/building-scalable-react-native-apps-with-nucleux-and-supabase</guid>
<pubDate>Tue, 15 Jul 2025 03:35:09 +0000</pubDate>
</item>
<item>
<title>Setting Up Next.js Authentication with Supabase: A Complete Guide</title>
<link>https://coderlegion.com/885/setting-up-next-js-authentication-with-supabase-a-complete-guide</link>
<description>&lt;p&gt;Hey there! Its been 2 weeks since my last blog. Damnnn don’t worry guys from this one i will be consistent from this time my promise. &lt;br&gt;
Today, I want to walk you through implementing authentication in your Next.js application using Supabase. I recently did this for a project which i’m building these along with open source contributions(stay tuned will share more details soon ), so I thought I’d share my experience to help others navigate this process more smoothly.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-what-we-need&quot;&gt;The Setup: What We Need&lt;/h3&gt;
&lt;p&gt;Before diving in, make sure you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A Next.js project (we'll use TypeScript)&lt;/li&gt;
&lt;li&gt;A Supabase account with a new project&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;@supabase/ssr&lt;/code&gt; package installed&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;creating-supabase-clients&quot;&gt;Creating Supabase Clients&lt;/h3&gt;
&lt;p&gt;The first thing we need to understand is that we'll need two different Supabase clients: one for the client-side and another for the server-side. Let's set those up!&lt;/p&gt;
&lt;h4 id=&quot;client-side-setup&quot;&gt;Client-Side Setup&lt;/h4&gt;
&lt;p&gt;In &lt;code&gt;utils/supabase/client.ts&lt;/code&gt;, we'll create our browser client:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;server-side-setup&quot;&gt;Server-Side Setup&lt;/h4&gt;
&lt;p&gt;For server components, we need a slightly different setup in &lt;code&gt;utils/supabase/server.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =&amp;gt;
              cookieStore.set(name, value, options)
            )
          } catch {
            // This can be ignored if you have middleware refreshing sessions
          }
        },
      },
    }
  )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;middleware-magic-keeping-sessions-fresh&quot;&gt;Middleware Magic: Keeping Sessions Fresh&lt;/h3&gt;
&lt;p&gt;Here's where it gets interesting! We need middleware to handle session refreshing. Create two files:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;middleware.ts&lt;/code&gt; at your project root:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'

export async function middleware(request: NextRequest) {
  return await updateSession(request)
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And &lt;code&gt;utils/supabase/middleware.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let response = NextResponse.next({ request })
  
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =&amp;gt; {
            response.cookies.set(name, value, options)
          })
        },
      },
    }
  )

  await supabase.auth.getUser()
  return response
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;creating-the-login-page&quot;&gt;Creating the Login Page&lt;/h3&gt;
&lt;p&gt;Now for the fun part - let's create a login page! Create &lt;code&gt;app/login/page.tsx&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { login, signup } from './actions'

export default function LoginPage() {
  return (
    &amp;lt;form&amp;gt;
      &amp;lt;label htmlFor=&quot;email&quot;&amp;gt;Email:&amp;lt;/label&amp;gt;
      &amp;lt;input id=&quot;email&quot; name=&quot;email&quot; type=&quot;email&quot; required /&amp;gt;
      &amp;lt;label htmlFor=&quot;password&quot;&amp;gt;Password:&amp;lt;/label&amp;gt;
      &amp;lt;input id=&quot;password&quot; name=&quot;password&quot; type=&quot;password&quot; required /&amp;gt;
      &amp;lt;button formAction={login}&amp;gt;Log in&amp;lt;/button&amp;gt;
      &amp;lt;button formAction={signup}&amp;gt;Sign up&amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
  )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the corresponding actions in &lt;code&gt;app/login/actions.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'

export async function login(formData: FormData) {
  const supabase = await createClient()
  
  const data = {
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  }

  const { error } = await supabase.auth.signInWithPassword(data)
  
  if (error) {
    redirect('/error')
  }

  revalidatePath('/', 'layout')
  redirect('/account')
}

export async function signup(formData: FormData) {
  const supabase = await createClient()
  
  const data = {
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  }

  const { error } = await supabase.auth.signUp(data)
  
  if (error) {
    redirect('/error')
  }

  revalidatePath('/', 'layout')
  redirect('/account')
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;pro-tips-and-gotchas&quot;&gt;Pro Tips and Gotchas&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Always use &lt;code&gt;getUser()&lt;/code&gt; for Protection&lt;/strong&gt;: When protecting pages, always use &lt;code&gt;supabase.auth.getUser()&lt;/code&gt; instead of &lt;code&gt;getSession()&lt;/code&gt;. It's more secure because it validates the auth token with Supabase's servers every time.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cookie Handling&lt;/strong&gt;: Server Components can't write cookies directly, which is why we need the middleware to handle session refreshing.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Route Protection&lt;/strong&gt;: Be careful when protecting routes - remember that cookies can be spoofed, so always verify the user session server-side.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Email Templates&lt;/strong&gt;: Don't forget to update your Supabase email templates to support server-side auth flows. You'll want to change the confirmation URL format in your email templates.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;what-s-next-&quot;&gt;What's Next?&lt;/h3&gt;
&lt;p&gt;Now that you have authentication set up, you might want to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Add social authentication providers&lt;/li&gt;
&lt;li&gt;Implement password reset functionality&lt;/li&gt;
&lt;li&gt;Create protected API routes&lt;/li&gt;
&lt;li&gt;Set up user profiles and additional data storage&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The foundation we've built here makes adding these features relatively straightforward.&lt;/p&gt;
&lt;p&gt;Remember to check out the &lt;a rel=&quot;nofollow&quot; href=&quot;https://supabase.com/docs&quot;&gt;Supabase docs&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://nextjs.org/docs&quot;&gt;Next.js documentation&lt;/a&gt; for more detailed information and advanced features!&lt;/p&gt;
&lt;p&gt;Happy coding! &lt;/p&gt;
</description>
<category>Getting Started</category>
<guid isPermaLink="true">https://coderlegion.com/885/setting-up-next-js-authentication-with-supabase-a-complete-guide</guid>
<pubDate>Wed, 22 Jan 2025 11:06:58 +0000</pubDate>
</item>
</channel>
</rss>