<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MACROGEN</title>
    <description>The latest articles on DEV Community by MACROGEN (@macrogenltd).</description>
    <link>https://dev.to/macrogenltd</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4098185%2F5d2896de-e51a-4a9c-b869-db39c7a7a33f.jpg</url>
      <title>DEV Community: MACROGEN</title>
      <link>https://dev.to/macrogenltd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/macrogenltd"/>
    <language>en</language>
    <item>
      <title>Multi-Tenant SaaS with Postgres Row-Level Security: A Practical Guide</title>
      <dc:creator>MACROGEN</dc:creator>
      <pubDate>Sun, 11 Oct 2026 21:49:39 +0000</pubDate>
      <link>https://dev.to/macrogenltd/multi-tenant-saas-with-postgres-row-level-security-a-practical-guide-3n3i</link>
      <guid>https://dev.to/macrogenltd/multi-tenant-saas-with-postgres-row-level-security-a-practical-guide-3n3i</guid>
      <description>&lt;p&gt;Almost every SaaS product has to answer one early question: &lt;strong&gt;how do we keep one customer's data away from another's?&lt;/strong&gt; Get it wrong and you have a data leak. Get it overcomplicated and you slow down every feature.&lt;/p&gt;

&lt;p&gt;This post covers the common options and walks through one of the most practical: a shared schema protected by Postgres row-level security (RLS).&lt;/p&gt;

&lt;h2&gt;
  
  
  The three common tenancy models
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Isolation&lt;/th&gt;
&lt;th&gt;Cost and ops&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shared schema (&lt;code&gt;tenant_id&lt;/code&gt; column)&lt;/td&gt;
&lt;td&gt;Logical&lt;/td&gt;
&lt;td&gt;Cheapest, simplest to run&lt;/td&gt;
&lt;td&gt;Many small tenants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema per tenant&lt;/td&gt;
&lt;td&gt;Stronger&lt;/td&gt;
&lt;td&gt;Migrations multiply&lt;/td&gt;
&lt;td&gt;Tens to hundreds of tenants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database per tenant&lt;/td&gt;
&lt;td&gt;Strongest&lt;/td&gt;
&lt;td&gt;Most expensive&lt;/td&gt;
&lt;td&gt;Compliance-heavy or large customers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most early-stage products start with the shared schema. The risk is simple: &lt;strong&gt;one forgotten &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt; leaks data.&lt;/strong&gt; RLS moves that rule from application code into the database, so it can't be forgotten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Add a tenant column
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt;  &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount&lt;/span&gt;     &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;invoices_tenant_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Index &lt;code&gt;tenant_id&lt;/code&gt; on every tenant-scoped table. Nearly every query will filter on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Enable row-level security
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;USING&lt;/code&gt; controls which rows you can &lt;strong&gt;read, update or delete&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WITH CHECK&lt;/code&gt; controls which rows you can &lt;strong&gt;insert or update into&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Table owners bypass RLS unless you use &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Superusers and roles with &lt;code&gt;BYPASSRLS&lt;/code&gt; always bypass it, so your application should connect as a normal, limited role.&lt;/li&gt;
&lt;li&gt;If the setting is missing, &lt;code&gt;current_setting(..., true)&lt;/code&gt; returns &lt;code&gt;NULL&lt;/code&gt;, the comparison is false, and &lt;strong&gt;no rows are returned&lt;/strong&gt;. That is a safe default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Set the tenant for each request
&lt;/h2&gt;

&lt;p&gt;Set the tenant inside a transaction, using &lt;code&gt;set_config&lt;/code&gt; with &lt;code&gt;true&lt;/code&gt; so it only lasts for that transaction. This also matters if you use a connection pool, because a setting that leaks to the next request would be dangerous.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// db.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT set_config('app.current_tenant', $1, true)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a route that uses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/invoices&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT id, amount, created_at FROM invoices ORDER BY created_at DESC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice there is no &lt;code&gt;WHERE tenant_id&lt;/code&gt; in that query. The database applies it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Test the isolation
&lt;/h2&gt;

&lt;p&gt;Write a test that proves tenant A cannot see tenant B's rows. This is the most valuable test in a multi-tenant app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tenants cannot read each other's invoices&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO invoices (tenant_id, amount) VALUES ($1, 100)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tenantA&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM invoices&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connecting as a superuser or table owner&lt;/strong&gt; silently disables RLS. Check your app's role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting to enable RLS on new tables.&lt;/strong&gt; Add a check to CI that lists tables with a &lt;code&gt;tenant_id&lt;/code&gt; column but no policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session-level settings with pooling.&lt;/strong&gt; Always use transaction-scoped settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background jobs and admin tools.&lt;/strong&gt; They need an explicit, audited way to set the tenant (or a separate role).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique constraints.&lt;/strong&gt; Usually they should include &lt;code&gt;tenant_id&lt;/code&gt;, for example &lt;code&gt;UNIQUE (tenant_id, email)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When RLS isn't enough
&lt;/h2&gt;

&lt;p&gt;RLS gives strong logical isolation, but it doesn't solve noisy neighbours (one tenant using all the resources), per-tenant backups or restores, or data-residency requirements. If a customer needs those, schema-per-tenant or database-per-tenant may fit better for them, and many products end up with a hybrid.&lt;/p&gt;

&lt;p&gt;What tenancy model are you using, and what made you pick it?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Further reading: &lt;a href="https://macro-gen.com/portfolio/saas-development/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=saas_development" rel="noopener noreferrer"&gt;SaaS Development&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>postgres</category>
      <category>architecture</category>
      <category>node</category>
    </item>
    <item>
      <title>Native vs Cross-Platform in 2026: Questions to Ask Before You Choose</title>
      <dc:creator>MACROGEN</dc:creator>
      <pubDate>Wed, 30 Sep 2026 19:12:48 +0000</pubDate>
      <link>https://dev.to/macrogenltd/native-vs-cross-platform-in-2026-questions-to-ask-before-you-choose-2p1i</link>
      <guid>https://dev.to/macrogenltd/native-vs-cross-platform-in-2026-questions-to-ask-before-you-choose-2p1i</guid>
      <description>&lt;p&gt;"Should we go native or cross-platform?" comes up at the start of almost every mobile project. The honest answer is that it depends, but it depends on specific, answerable questions. Here are the ones that actually decide it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. How much of the app is platform-specific?
&lt;/h2&gt;

&lt;p&gt;If the app leans heavily on the camera, Bluetooth, background processing, AR, widgets or the newest OS features on release day, native (Swift / Kotlin) avoids a layer of bridging and plugin maintenance.&lt;/p&gt;

&lt;p&gt;If the app is mostly forms, lists, dashboards and API calls, which describes most business apps, cross-platform handles it comfortably.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What does your team already know?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A strong &lt;strong&gt;JavaScript/TypeScript and React&lt;/strong&gt; team will be productive in &lt;strong&gt;React Native&lt;/strong&gt; quickly, and can often share logic with a web app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flutter&lt;/strong&gt; uses Dart, which is easy to pick up, and gives very consistent UI across platforms because it draws its own widgets.&lt;/li&gt;
&lt;li&gt;Separate &lt;strong&gt;iOS and Android&lt;/strong&gt; specialists make native realistic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The best framework is often the one your team can maintain for years, not the one that wins benchmarks.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. How important is identical UI on both platforms?
&lt;/h2&gt;

&lt;p&gt;Flutter renders the same pixels everywhere, which is great for strongly branded designs. React Native and native apps use platform components, which feel more "at home" on each OS but differ slightly between them. Decide which your users and designers actually want.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. What's the performance profile?
&lt;/h2&gt;

&lt;p&gt;For typical business apps, modern cross-platform frameworks are more than fast enough. Performance becomes a deciding factor mainly for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Heavy graphics, games or real-time video&lt;/li&gt;
&lt;li&gt;Complex animations on low-end devices&lt;/li&gt;
&lt;li&gt;Intensive on-device processing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even then, both Flutter and React Native let you drop down to native code for the hot paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What's the long-term cost?
&lt;/h2&gt;

&lt;p&gt;Two native codebases mean roughly two sets of features, bugs and releases. Cross-platform usually reduces that, but you take on dependency risk: plugin quality, framework upgrades and lag behind new OS features.&lt;/p&gt;

&lt;p&gt;Ask: &lt;em&gt;Who maintains this in three years, and how?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick decision guide
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Leaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Business app, small team, tight budget&lt;/td&gt;
&lt;td&gt;Cross-platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React/web team, shared logic with web&lt;/td&gt;
&lt;td&gt;React Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Highly branded, pixel-consistent UI&lt;/td&gt;
&lt;td&gt;Flutter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deep hardware or OS integration&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Graphics-heavy or performance-critical&lt;/td&gt;
&lt;td&gt;Native (or cross-platform plus native modules)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;There's no universally right answer, only a right answer for your project, team and budget. Write down your answers to the five questions above before anyone mentions a framework name, and the choice usually becomes obvious.&lt;/p&gt;

&lt;p&gt;Which way did you go on your last project, and would you choose the same again? Let me know in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work at MACROGEN, where we build native and cross-platform apps. You can see our mobile app projects &lt;a href="https://macro-gen.com/portfolio/mobile-app-development/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=mobile_app_02" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI and reviewed for accuracy by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>flutter</category>
      <category>reactnative</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Building Offline-First Mobile Apps: A Practical Guide to Sync Queues</title>
      <dc:creator>MACROGEN</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:31:54 +0000</pubDate>
      <link>https://dev.to/macrogenltd/building-offline-first-mobile-apps-a-practical-guide-to-sync-queues-4km1</link>
      <guid>https://dev.to/macrogenltd/building-offline-first-mobile-apps-a-practical-guide-to-sync-queues-4km1</guid>
      <description>&lt;p&gt;Users don't care whether they're on a train, in a lift or in a building with terrible signal. They expect your app to work. Offline-first design treats the network as optional: the app reads and writes locally, then syncs when it can.&lt;/p&gt;

&lt;p&gt;Here's a pattern that works well in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The local database is the source of truth for the UI
&lt;/h2&gt;

&lt;p&gt;The UI never waits on the network. It reads from and writes to a local database (SQLite, Realm, WatermelonDB, Drift and so on). Network responses update the local database, and the UI reacts to those changes.&lt;/p&gt;

&lt;p&gt;This alone makes an app feel dramatically faster, even online.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Queue every change in an "outbox"
&lt;/h2&gt;

&lt;p&gt;When the user makes a change, write it locally and add an entry to an outbox table in the same transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;OutboxItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// unique ID, generated on the device&lt;/span&gt;
  &lt;span class="nl"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;task&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A background sync worker then processes the outbox in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flushOutbox&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;createdAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// keep ordering; retry later with backoff&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trigger it when connectivity returns, when the app comes to the foreground, and on a timer.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Make requests idempotent
&lt;/h2&gt;

&lt;p&gt;Mobile networks fail in awkward ways. A request can succeed on the server while the response never reaches the phone, so the app retries. Without protection, that creates duplicates.&lt;/p&gt;

&lt;p&gt;Send the outbox item's ID as an idempotency key. The server stores keys it has already processed and returns the original result for repeats instead of applying the change twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Decide on a conflict strategy up front
&lt;/h2&gt;

&lt;p&gt;If two devices edit the same record offline, something has to give. Common options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Last write wins.&lt;/strong&gt; Simple, but can silently discard edits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field-level merge.&lt;/strong&gt; Only fields that were actually changed get overwritten. This suits forms and profiles well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the user.&lt;/strong&gt; Best for high-value data like documents or orders.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you choose, store an &lt;code&gt;updatedAt&lt;/code&gt; or version number on each record so conflicts can be detected rather than guessed.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Show sync status honestly
&lt;/h2&gt;

&lt;p&gt;A small "Saved on device, syncing…" indicator builds far more trust than pretending everything reached the server. Surface failed syncs clearly so users aren't surprised later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] UI reads only from the local database&lt;/li&gt;
&lt;li&gt;[ ] Writes and outbox entries happen in one transaction&lt;/li&gt;
&lt;li&gt;[ ] Retries use exponential backoff&lt;/li&gt;
&lt;li&gt;[ ] Every request carries an idempotency key&lt;/li&gt;
&lt;li&gt;[ ] Conflict strategy is documented and tested&lt;/li&gt;
&lt;li&gt;[ ] Sync status is visible to the user&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Offline-first takes more thought up front, but it pays off in reliability and user trust. Have you built offline sync before? What caught you out?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work at MACROGEN, where we build iOS and Android apps. Some of our mobile work is &lt;a href="https://macro-gen.com/portfolio/mobile-app-development/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=mobile_app_02" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI and reviewed for accuracy by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>reactnative</category>
      <category>flutter</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Multi-Tenancy in SaaS: Choosing Between Shared Tables, Schemas and Separate Databases</title>
      <dc:creator>MACROGEN</dc:creator>
      <pubDate>Tue, 29 Sep 2026 01:15:45 +0000</pubDate>
      <link>https://dev.to/macrogenltd/multi-tenancy-in-saas-choosing-between-shared-tables-schemas-and-separate-databases-1jl6</link>
      <guid>https://dev.to/macrogenltd/multi-tenancy-in-saas-choosing-between-shared-tables-schemas-and-separate-databases-1jl6</guid>
      <description>&lt;p&gt;One of the first big decisions when building a SaaS product is how to keep each customer's data separate. Get it wrong and you either leak data between tenants or end up with an architecture that can't scale. Here are the three common approaches and when each one makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Shared database, shared tables
&lt;/h2&gt;

&lt;p&gt;Every table has a &lt;code&gt;tenant_id&lt;/code&gt; column, and every query filters on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cheapest to run and simplest to deploy&lt;/li&gt;
&lt;li&gt;One migration updates every tenant&lt;/li&gt;
&lt;li&gt;Easy cross-tenant reporting for your own analytics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One forgotten &lt;code&gt;WHERE tenant_id = ...&lt;/code&gt; and you have a data leak&lt;/li&gt;
&lt;li&gt;A single noisy tenant can slow everyone down&lt;/li&gt;
&lt;li&gt;Harder to offer per-customer backups or data residency&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Shared database, schema per tenant
&lt;/h2&gt;

&lt;p&gt;Each tenant gets its own schema (&lt;code&gt;tenant_acme.invoices&lt;/code&gt;, &lt;code&gt;tenant_globex.invoices&lt;/code&gt;), and you switch the &lt;code&gt;search_path&lt;/code&gt; per request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stronger logical separation&lt;/li&gt;
&lt;li&gt;Per-tenant restores are easier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Migrations must run once per schema, which gets slow with thousands of tenants&lt;/li&gt;
&lt;li&gt;Connection pooling becomes trickier&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Database per tenant
&lt;/h2&gt;

&lt;p&gt;Each tenant gets a completely separate database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Strongest isolation, and a natural fit for enterprise customers and data residency requirements&lt;/li&gt;
&lt;li&gt;Noisy neighbours are no longer a problem&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Highest cost and operational overhead&lt;/li&gt;
&lt;li&gt;Fleet-wide migrations and monitoring need proper tooling&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Making option 1 safer with Row-Level Security
&lt;/h2&gt;

&lt;p&gt;Most early-stage products start with shared tables. You can remove the "forgotten WHERE clause" risk by letting PostgreSQL enforce isolation with Row-Level Security (RLS):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, at the start of each request's transaction, set the tenant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'6f1c2a9e-...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- only this tenant's rows come back&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to watch out for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Table owners bypass RLS&lt;/strong&gt; unless you use &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;, and superusers always bypass it. Your app should connect as a separate, non-owner role.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;SET LOCAL&lt;/code&gt; inside a transaction so the setting can't leak to the next request on a pooled connection.&lt;/li&gt;
&lt;li&gt;Index &lt;code&gt;tenant_id&lt;/code&gt; (usually as the first column in composite indexes), or performance will suffer as you grow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which should you pick?
&lt;/h2&gt;

&lt;p&gt;A sensible default for most new SaaS products:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with shared tables plus RLS.&lt;/strong&gt; It's cheap, simple and safe enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design your code so the tenant context is resolved in one place&lt;/strong&gt; (middleware, not scattered queries). That makes moving to another model later much less painful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offer database-per-tenant as an enterprise tier&lt;/strong&gt; if customers ask for dedicated infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How are you handling multi-tenancy in your projects? I'd love to hear what's worked, or not, in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work at MACROGEN, where we build SaaS platforms. You can see some of our SaaS work &lt;a href="https://macro-gen.com/portfolio/saas-development/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=saas_01" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI and reviewed for accuracy by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>postgres</category>
      <category>architecture</category>
      <category>database</category>
    </item>
    <item>
      <title>A Practical Full-Stack Architecture for Production Apps (React, Next.js, Node.js &amp; Express)</title>
      <dc:creator>MACROGEN</dc:creator>
      <pubDate>Fri, 25 Sep 2026 07:54:18 +0000</pubDate>
      <link>https://dev.to/macrogenltd/a-practical-full-stack-architecture-for-production-apps-react-nextjs-nodejs-express-1mm6</link>
      <guid>https://dev.to/macrogenltd/a-practical-full-stack-architecture-for-production-apps-react-nextjs-nodejs-express-1mm6</guid>
      <description>&lt;h1&gt;
  
  
  A Practical Full-Stack Architecture for Production Apps (React, Next.js, Node.js &amp;amp; Express)
&lt;/h1&gt;

&lt;p&gt;Every "how to build a full-stack app" tutorial online teaches you how to build a to-do list. Almost none of them teach you what to do when that to-do list turns into a real product with real users, real auth, real edge cases, and a client asking "can we add X" every second sprint.&lt;/p&gt;

&lt;p&gt;This is the architecture pattern we default to when building production web apps with React, Next.js, Node.js, and Express — not because it's the only way to do it, but because it scales cleanly from a 2-week MVP to a platform handling real traffic, without a rewrite in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Decision: Where Does Next.js End and Express Begin?
&lt;/h2&gt;

&lt;p&gt;This is the question that trips up most teams early on. Next.js ships with API routes out of the box, so it's tempting to build your entire backend inside &lt;code&gt;pages/api&lt;/code&gt; (or &lt;code&gt;app/api&lt;/code&gt; if you're on the App Router) and call it done.&lt;/p&gt;

&lt;p&gt;For small apps, that's fine. For anything with real business logic — background jobs, webhooks, complex auth, integrations with third-party services — we split it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/apps
  /web        → Next.js (frontend + BFF layer)
  /api        → Express (core backend, business logic, DB access)
/packages
  /shared     → shared types, validation schemas, constants
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why split it at all?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js's API routes run in a serverless-friendly environment, which isn't always ideal for long-running jobs, websockets, or heavy background processing.&lt;/li&gt;
&lt;li&gt;A dedicated Express API can be deployed independently, scaled independently, and reused by other clients later — a mobile app, a partner integration, whatever comes next.&lt;/li&gt;
&lt;li&gt;It keeps your frontend team's mental model simple: Next.js handles UI, routing, and light data-fetching glue. Express owns the actual business logic and data layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building something genuinely small and won't need to reuse the backend elsewhere, keeping everything inside Next.js is a completely valid call. Just know it's a decision, not a default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Folder Structure That Doesn't Fall Apart at Scale
&lt;/h2&gt;

&lt;p&gt;A structure like this tends to hold up well as the codebase grows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/apps/api/src
  /modules
    /users
      users.controller.ts
      users.service.ts
      users.routes.ts
      users.schema.ts
    /billing
      billing.controller.ts
      billing.service.ts
      billing.routes.ts
  /middleware
    auth.middleware.ts
    error.middleware.ts
  /lib
    db.ts
    logger.ts
  app.ts
  server.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key idea: organize by feature/domain, not by technical layer. A "controllers/", "services/", "routes/" split at the top level looks tidy in a demo repo, but it means every new feature touches three unrelated folders. Six months in, nobody can find anything. Grouping by module keeps related code physically close together.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Minimal but Realistic Express Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;errorMiddleware&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./middleware/error.middleware&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;authMiddleware&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./middleware/auth.middleware&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userRoutes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./modules/users/users.routes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userRoutes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errorMiddleware&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// always last&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Error middleware goes last, always.&lt;/strong&gt; New devs on the team forget this constantly, and it silently breaks error handling across the whole app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth is middleware, not a helper function&lt;/strong&gt; sprinkled into every controller. It should be impossible to accidentally forget to protect a route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation happens at the edge&lt;/strong&gt;, before anything touches your service layer. We use &lt;code&gt;zod&lt;/code&gt; for this — schemas double as TypeScript types, so your validation and your types never drift apart:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// users.schema.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;createUserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CreateUserInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;createUserSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Frontend Side: Next.js as More Than Just Pages
&lt;/h2&gt;

&lt;p&gt;On the Next.js side, the biggest architectural decision is how you fetch and cache data. With the App Router, Server Components let you fetch data directly on the server without shipping a client-side fetch waterfall — which matters more than people expect for perceived performance.&lt;/p&gt;

&lt;p&gt;A pattern that works well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/dashboard/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getUserData&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DashboardPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUserData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// runs on the server&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DashboardClient&lt;/span&gt; &lt;span class="na"&gt;initialUser&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// components/DashboardClient.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DashboardClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;initialUser&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialUser&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// interactive UI logic here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server Components handle the initial data fetch with zero client-side loading spinner. Client Components take over for anything interactive. Mixing these deliberately — rather than marking everything &lt;code&gt;"use client"&lt;/code&gt; out of habit — is one of the easiest performance wins available in a modern Next.js app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth: The One Place Not to Get Creative
&lt;/h2&gt;

&lt;p&gt;Roll your own auth once, learn why you shouldn't, and after that most teams land on one of two approaches:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A managed auth provider&lt;/strong&gt; (Auth0, Clerk, Supabase Auth) — faster to ship, less to maintain, worth it for most products.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT-based auth with refresh tokens&lt;/strong&gt;, issued by your Express API, verified via middleware on protected routes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you go the JWT route, the pattern that avoids the most footguns is: short-lived access tokens (15 minutes or so) stored in memory on the client, and a long-lived refresh token stored in an httpOnly cookie. It's slightly more setup than storing a token in &lt;code&gt;localStorage&lt;/code&gt;, but it closes off an entire category of XSS-related token theft.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment: Keep the Two Halves Independently Deployable
&lt;/h2&gt;

&lt;p&gt;Because the frontend and backend are separate apps, they can (and should) deploy independently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js app&lt;/strong&gt; → Vercel, or a container on AWS/Fly.io if you need more control over server-side behavior.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Express API&lt;/strong&gt; → containerized with Docker, deployed to AWS ECS, Fly.io, or Railway, sitting behind a load balancer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared package&lt;/strong&gt; → published internally or symlinked via a monorepo tool (Turborepo, Nx) so both apps stay in sync on types and validation without copy-pasting code between repos.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This also means your API can scale independently from your frontend — useful the moment you have a traffic spike on one side but not the other, which happens more often than people expect (a marketing campaign spikes frontend traffic; a batch job spikes backend load — rarely both at once).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Lesson Here
&lt;/h2&gt;

&lt;p&gt;None of this is exotic. There's no framework magic, no clever trick. The architecture that survives contact with a real product is almost always the boring one: clear boundaries between frontend and backend, code organized by feature instead of by layer, validation that can't be skipped, and auth that nobody "gets creative" with.&lt;/p&gt;

&lt;p&gt;Most of the pain in full-stack projects doesn't come from picking the wrong framework — it comes from structure that made sense on day one and stopped making sense on day ninety. Deciding on these boundaries early, even for something that starts small, saves a rewrite later.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm part of the team at Macro Gen, a Christchurch-based dev studio building full-stack web apps, SaaS platforms, and cloud infrastructure with this same stack. If you're working through a similar architecture decision or want a second opinion on your setup, happy to chat — drop a comment or find us on Facebook.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>fullstack</category>
      <category>node</category>
      <category>react</category>
    </item>
    <item>
      <title>Multi-Tenancy in SaaS: The Architecture Decision You Can't Undo Later</title>
      <dc:creator>MACROGEN</dc:creator>
      <pubDate>Fri, 25 Sep 2026 07:52:14 +0000</pubDate>
      <link>https://dev.to/macrogenltd/multi-tenancy-in-saas-the-architecture-decision-you-cant-undo-later-4l8c</link>
      <guid>https://dev.to/macrogenltd/multi-tenancy-in-saas-the-architecture-decision-you-cant-undo-later-4l8c</guid>
      <description>&lt;h1&gt;
  
  
  Multi-Tenancy in SaaS: The Architecture Decision You Can't Undo Later
&lt;/h1&gt;

&lt;p&gt;Most early architecture decisions in a SaaS product are reversible. Wrong framework? Migrate it. Wrong hosting provider? Move it. Wrong multi-tenancy model? That one's expensive to fix once you have real customers and real data sitting in production.&lt;/p&gt;

&lt;p&gt;If you're building a SaaS platform and haven't explicitly decided how tenant data is isolated, you've actually already made the decision by default — usually the one that's easiest to type, not the one that scales best. Here's a practical breakdown of the three real options, when each one makes sense, and what we typically default to when architecting SaaS platforms on PostgreSQL, MongoDB, and AWS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Real Options
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Shared Database, Shared Schema (Row-Level Isolation)
&lt;/h3&gt;

&lt;p&gt;Every tenant's data lives in the same tables, distinguished by a &lt;code&gt;tenant_id&lt;/code&gt; column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_invoices_tenant&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; Cheapest to run, simplest migrations (one schema to update, ever), easiest to query across tenants for internal analytics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; One bad query without a &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt; clause and you've got a serious data leak. This is the model where a single missing filter is a headline-worthy incident, not a minor bug.&lt;/p&gt;

&lt;p&gt;The fix most teams reach for is enforcing isolation at the database level instead of trusting every developer to remember the filter every time — Postgres Row-Level Security does exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;app.current_tenant&lt;/code&gt; at the start of every request (from the authenticated session), and Postgres enforces isolation even if a developer forgets the &lt;code&gt;WHERE&lt;/code&gt; clause. This single feature is, in our experience, the difference between "safe shared-schema multi-tenancy" and "multi-tenancy that's one code review miss away from a very bad day."&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Shared Database, Schema-per-Tenant
&lt;/h3&gt;

&lt;p&gt;Each tenant gets their own Postgres schema within the same database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;tenant_acme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;tenant_globex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; Stronger isolation than row-level, still relatively cheap to run since it's one physical database. Backups and per-tenant data export are cleaner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; Migrations become a loop instead of a single command — you're now running your migration script against every tenant schema, which gets slow and error-prone past a few hundred tenants. Connection pooling also gets trickier since schema-switching happens per request.&lt;/p&gt;

&lt;p&gt;This tends to be the right call for B2B SaaS with a moderate number of larger customers (tens to low hundreds), especially where customers explicitly ask about data isolation for compliance reasons — schema-per-tenant is much easier to explain in a security questionnaire than "we use a WHERE clause."&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Database-per-Tenant
&lt;/h3&gt;

&lt;p&gt;Each tenant gets a fully separate database, sometimes even a separate instance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; The strongest isolation available short of separate infrastructure entirely. Easiest model to reason about for compliance-heavy industries (health, finance, government contracts) and the only option in this list where "delete this tenant's data completely" is a single &lt;code&gt;DROP DATABASE&lt;/code&gt; rather than a targeted deletion job you have to trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; Expensive, and operationally heavier — connection management, monitoring, and backups all multiply per tenant. Cross-tenant analytics (e.g. "average usage across all customers") requires a separate data pipeline rather than a simple query.&lt;/p&gt;

&lt;p&gt;This is usually reserved for enterprise tiers — a single "Enterprise" plan tenant gets database-per-tenant while your standard tiers stay on shared-schema, giving you the best of both without paying enterprise infrastructure costs for every free-tier signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where MongoDB Fits
&lt;/h2&gt;

&lt;p&gt;For SaaS products with more flexible, document-shaped data (activity feeds, configuration objects, event logs), we often pair Postgres for core transactional data (billing, users, tenants) with MongoDB for high-volume, schema-flexible data. Tenant isolation in Mongo typically follows the same three patterns — a &lt;code&gt;tenantId&lt;/code&gt; field with proper indexing for the shared-collection model, or separate collections/databases per tenant for stronger isolation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createIndex&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentTenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startOfMonth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same rule applies here as in Postgres: never trust application code alone to filter by tenant on every query. Build the check as close to the data layer as possible — a repository/query-builder layer that automatically injects the tenant filter is far safer than hoping every engineer remembers it in every service file.&lt;/p&gt;

&lt;h2&gt;
  
  
  File Storage: Isolating Tenants in S3
&lt;/h2&gt;

&lt;p&gt;This one gets overlooked constantly. If tenants upload files — documents, images, exports — namespace every object by tenant from day one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;s3://your-app-uploads/{tenant_id}/{resource_type}/{file_id}.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combine this with per-tenant scoped IAM policies or presigned URLs generated server-side (never client-side) so a tenant can only ever generate a signed URL for objects under their own prefix. Retrofitting this after tenants already have thousands of files under a flat structure is a genuinely painful migration — worth getting right before your first real customer uploads anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  So Which One Should You Actually Pick?
&lt;/h2&gt;

&lt;p&gt;If you're building an MVP and don't yet know your customer profile: start with shared schema + Row-Level Security. It's the cheapest to run, fast to build, and RLS closes off the scariest failure mode. You can migrate specific large or compliance-sensitive tenants to their own schema or database later — this is a much easier migration to do selectively than to do a full switch of your isolation model after the fact.&lt;/p&gt;

&lt;p&gt;If you already know you're selling to enterprise or regulated industries from day one, it's worth designing for schema-per-tenant or database-per-tenant from the start, even though it's more setup — retrofitting isolation once you have production data and paying customers is a significantly bigger project than building it in from the beginning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Takeaway
&lt;/h2&gt;

&lt;p&gt;Multi-tenancy isn't a checkbox — it's a decision that touches your database schema, your query layer, your file storage, your backup strategy, and eventually your compliance story. The teams that get burned are almost always the ones that let the isolation model happen implicitly (usually shared-schema with no RLS and a prayer that nobody forgets a &lt;code&gt;WHERE&lt;/code&gt; clause) instead of choosing it deliberately.&lt;/p&gt;

&lt;p&gt;Pick the model that matches who you're selling to today, build in the database-level safety net regardless of which model you choose, and revisit the decision explicitly once your customer base or compliance requirements change — not after an incident forces the conversation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on SaaS architecture at Macro Gen, a Christchurch-based dev studio building SaaS platforms end-to-end on Postgres, MongoDB, and AWS. If you're mid-decision on your own multi-tenancy model, happy to compare notes — drop a comment or find us on Facebook.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>postgres</category>
      <category>mongodb</category>
      <category>aws</category>
    </item>
    <item>
      <title>The CI/CD Pipeline Audit I Wish Someone Made Me Do Sooner</title>
      <dc:creator>MACROGEN</dc:creator>
      <pubDate>Mon, 21 Sep 2026 21:32:53 +0000</pubDate>
      <link>https://dev.to/macrogenltd/the-cicd-pipeline-audit-i-wish-someone-made-me-do-sooner-1k8a</link>
      <guid>https://dev.to/macrogenltd/the-cicd-pipeline-audit-i-wish-someone-made-me-do-sooner-1k8a</guid>
      <description>&lt;p&gt;Most teams don't design their CI/CD pipeline once and leave it. It grows organically — a script here, a manual approval step there, a workaround for that one flaky test nobody's fixed — until eighteen months later you have a pipeline that technically works but that nobody fully understands anymore.&lt;/p&gt;

&lt;p&gt;Here's a rough audit checklist that's caught real problems before they turned into outages.&lt;/p&gt;

&lt;p&gt;Who can actually deploy, and how would you know?&lt;/p&gt;

&lt;p&gt;Ask your team: if a deploy went out at 3 a.m. and broke something, could you tell who triggered it and why, without digging through Slack? If deploy permissions are scattered across five people's personal access tokens instead of a service account with proper audit logging, that's worth fixing before it's the reason an incident postmortem takes three days instead of three hours.&lt;/p&gt;

&lt;p&gt;How long does a rollback actually take?&lt;/p&gt;

&lt;p&gt;Not "how long should it take" — actually time it. Trigger a rollback in staging and watch the clock. Teams are often surprised to learn their "quick rollback" involves a manual database step nobody automated, or a cache that needs to be flushed by hand. If nobody's timed it recently, that number in your head is probably wrong.&lt;/p&gt;

&lt;p&gt;Are your environments actually identical?&lt;/p&gt;

&lt;p&gt;"Works in staging" followed by a production failure is almost always an environment drift problem — a different Node version, a missing environment variable, a dependency that got pinned in one place and not the other. Infrastructure-as-code helps, but only if staging and production are both built from the same definitions rather than staging being hand-configured once and forgotten.&lt;/p&gt;

&lt;p&gt;What happens when a test suite gets slow?&lt;/p&gt;

&lt;p&gt;There's a predictable lifecycle: tests get slow, someone adds a skip flag "temporarily," and six months later half the suite is skipped and nobody remembers why. A slow pipeline is a signal, not just an inconvenience — it usually means either the test suite needs pruning or the infrastructure running it needs a second look.&lt;/p&gt;

&lt;p&gt;Is your monitoring watching the right things?&lt;/p&gt;

&lt;p&gt;Uptime monitoring is table stakes. The gap that catches teams off guard is usually deployment-correlated monitoring — being able to see "error rates spiked 4 minutes after deploy X" without manually cross-referencing timestamps. If that correlation takes more than a glance, it's worth wiring up.&lt;/p&gt;

&lt;p&gt;Worth doing before the pipeline tests you instead&lt;/p&gt;

&lt;p&gt;None of these questions require exotic tooling to answer. Most of it is just sitting down and actually testing assumptions that have been sitting unchallenged for a while. Worth doing before the pipeline decides to test them for you.&lt;/p&gt;

&lt;p&gt;Macro-Gen works through this kind of infrastructure and pipeline review with teams regularly — happy to compare notes if you're in the middle of one.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>cloud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The MVP Features That Are Actually Just Multi-Tenancy in Disguise</title>
      <dc:creator>MACROGEN</dc:creator>
      <pubDate>Mon, 21 Sep 2026 21:31:57 +0000</pubDate>
      <link>https://dev.to/macrogenltd/the-mvp-features-that-are-actually-just-multi-tenancy-in-disguise-439d</link>
      <guid>https://dev.to/macrogenltd/the-mvp-features-that-are-actually-just-multi-tenancy-in-disguise-439d</guid>
      <description>&lt;p&gt;When people talk about "MVP scope," it's usually framed as a features conversation — what to cut, what to keep, what can wait until v2. But some of the decisions hiding inside that conversation aren't really about features at all. They're architecture decisions wearing a feature costume, and they're a lot more expensive to walk back than a UI tweak.&lt;/p&gt;

&lt;p&gt;Here are a few that show up disguised as "small" scope questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Can users belong to more than one workspace?"
&lt;/h2&gt;

&lt;p&gt;This sounds like a small toggle. It's actually a foundational question about how you model the relationship between users and tenants. Bolt this on after launch and you're often looking at a schema migration touching every table that references a user, not just adding a join table. If there's any chance of this in year one, it's worth deciding the data model up front even if the UI for it ships later.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Do we need custom roles per customer?"
&lt;/h2&gt;

&lt;p&gt;Early on, "admin" and "member" covers almost everyone. Then one enterprise prospect asks for a "billing viewer who can't touch settings" and the two-role system doesn't bend easily. Permissions systems are one of those things that are far cheaper to build with some flexibility from the start (even if you expose only two roles at launch) than to retrofit once real customers depend on the existing behavior not changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Should each customer get a subdomain?"
&lt;/h2&gt;

&lt;p&gt;This one seems cosmetic — acme.yourapp.com feels like a branding nice-to-have. But once you commit to subdomain-based tenancy, it touches routing, SSL certificate provisioning, session handling, and sometimes your auth flow. Deciding this after a handful of customers are already live on a shared URL structure means a migration, not a config change.&lt;/p&gt;

&lt;h2&gt;
  
  
  "What happens when a customer wants to export everything and leave?"
&lt;/h2&gt;

&lt;p&gt;Nobody wants to build this for an MVP, understandably. But designing your data model with clean tenant boundaries from day one makes an eventual export/delete flow straightforward. Design it without that boundary, and "delete this one customer's data" turns into a research project through years of accumulated joins and shared tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Do we support one plan or several?"
&lt;/h2&gt;

&lt;p&gt;A single flat price is the right call for validating an MVP. The trap is baking that assumption into billing logic in a way that assumes exactly one price forever — no fields for plan tier, no separation between "what a customer is billed" and "what a customer is entitled to." That separation costs almost nothing to add early and saves a rebuild later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not over-engineering — just noticing the disguise
&lt;/h2&gt;

&lt;p&gt;None of this means over-engineering the MVP — the goal is still to ship something small and learn fast. It just means noticing which "small" decisions are actually architecture decisions before they get made by default.&lt;/p&gt;

&lt;p&gt;Macro-Gen helps SaaS teams work through exactly this kind of scoping before the first migration gets written.&lt;/p&gt;

</description>
      <category>sass</category>
      <category>architecture</category>
      <category>startup</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
