<?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: Nabeel Hassan</title>
    <description>The latest articles on DEV Community by Nabeel Hassan (@nabeelbaghoor).</description>
    <link>https://dev.to/nabeelbaghoor</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%2F900791%2Fc3f13182-5a2c-42a8-ba1c-645572cbf415.jpg</url>
      <title>DEV Community: Nabeel Hassan</title>
      <link>https://dev.to/nabeelbaghoor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nabeelbaghoor"/>
    <language>en</language>
    <item>
      <title>What I learned building a white-label, multi-tenant SaaS from scratch</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sun, 12 Jul 2026 18:44:27 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/what-i-learned-building-a-white-label-multi-tenant-saas-from-scratch-2p76</link>
      <guid>https://dev.to/nabeelbaghoor/what-i-learned-building-a-white-label-multi-tenant-saas-from-scratch-2p76</guid>
      <description>&lt;p&gt;A few months ago I set out to build VoiceDash, a white-label portal that lets an agency resell AI voice agents under their own brand. The pitch is simple: an agency buys voice AI infrastructure once, slaps their logo and domain on it, and hands each of their clients a clean dashboard that looks like the agency built it themselves. Simple to say. The build is where you find out what "white-label" and "multi-tenant" actually cost you.&lt;/p&gt;

&lt;p&gt;This is a build log, not a launch post. I want to write down the three things that turned out to be harder than I expected, while they are still fresh.&lt;/p&gt;

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

&lt;p&gt;Nothing exotic, and that was on purpose. Next.js App Router, Prisma on top of Postgres (hosted on Supabase), NextAuth for sessions, Stripe for billing, Resend for transactional email, and Retell as the underlying voice platform. The UI is Radix primitives with Tailwind, recharts for the call analytics, and react-grid-layout so clients can rearrange their dashboard widgets. OpenAI does the call analysis pass. Deploys are just a push to &lt;code&gt;main&lt;/code&gt; on Vercel.&lt;/p&gt;

&lt;p&gt;I picked boring tools everywhere I could, because the actual hard problems were not going to be about the framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard problem 1: multi-tenancy is a discipline, not a feature
&lt;/h2&gt;

&lt;p&gt;Every SaaS tutorial shows you a &lt;code&gt;User&lt;/code&gt; table and a &lt;code&gt;Post&lt;/code&gt; table and calls it a day. A white-label agency product has a shape most tutorials skip: there are two layers of tenants. The agency is a tenant of my platform, and each of the agency's clients is a tenant of the agency. So the data model became &lt;code&gt;Workspace&lt;/code&gt; (the agency) owns &lt;code&gt;Client&lt;/code&gt; records, and both users and clients hang off the workspace.&lt;/p&gt;

&lt;p&gt;The part nobody warns you about is that multi-tenancy is not something you turn on. It is a rule you have to obey on every single query, forever. Every read and every write has to be scoped by &lt;code&gt;workspaceId&lt;/code&gt;, and the one time you forget, an agency sees another agency's call data. That is not a bug you patch, that is the whole product losing trust in one screenshot.&lt;/p&gt;

&lt;p&gt;The pattern I settled on is that the workspace id lives on the session, not in the request body. When a request comes in, I pull &lt;code&gt;workspaceId&lt;/code&gt; off the authenticated session and use that as the filter. The client never gets to tell me which workspace they are in. A typical route looks like this:&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;const&lt;/span&gt; &lt;span class="nx"&gt;session&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;auth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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;workspaceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No workspace&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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;agent&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&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="nx"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;workspaceId&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 the &lt;code&gt;findFirst&lt;/code&gt; with both &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;workspaceId&lt;/code&gt;. Not &lt;code&gt;findUnique&lt;/code&gt; on the id alone. Even though the id is a cuid and effectively unguessable, I never look something up by id without also constraining the tenant. It feels redundant every time you type it. It is the single most important line in the codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard problem 2: custom domains are where the fantasy meets DNS
&lt;/h2&gt;

&lt;p&gt;The whole selling point of white-label is that the agency's client visits &lt;code&gt;portal.theiragency.com&lt;/code&gt; and never sees my brand anywhere. That means real custom domains, one per agency, resolving to the same app but rendering a completely different skin.&lt;/p&gt;

&lt;p&gt;The rendering side turned out to be the easy half. I store branding on the workspace: logo, login logo, favicon, a color theme, even the browser tab title. When a request arrives I look up the workspace by its custom domain and hydrate the whole UI from those fields. The color theme is a single hex value that becomes a CSS variable, so one agency is blue and the next is green without a rebuild.&lt;/p&gt;

&lt;p&gt;The hard half is everything around DNS and certificates. Vercel provisions SSL automatically once a domain points at it, but you are now asking non-technical agency owners to add records at a registrar they may not fully control. I ended up building a domain settings endpoint that just stores the requested domain on the workspace and walks them through the DNS step, then verification happens out of band. The lesson: the code to accept a custom domain is fifteen lines. The support surface it opens is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard problem 3: reselling somebody else's API under your customer's key
&lt;/h2&gt;

&lt;p&gt;VoiceDash does not run its own voice models. It sits on top of Retell. That raises an awkward question: whose API key makes the call?&lt;/p&gt;

&lt;p&gt;The answer I landed on is a fallback chain. An individual agent can carry its own key, and if it does not, the request falls back to the workspace-level integration key for that platform:&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;let&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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;integration&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RETELL&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;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;integration&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No Retell API key found&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then syncing call history is just hitting Retell's &lt;code&gt;list-calls&lt;/code&gt; endpoint with that key, filtered to the agent's platform id, and upserting the results into my own &lt;code&gt;Conversation&lt;/code&gt; table so the dashboard reads from Postgres instead of hammering their API on every page load. That upsert-into-my-own-table decision matters more than it looks. It means my analytics, my search, and my AI analysis all run on data I own, and the third-party API is a sync source, not a live dependency of every render.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;p&gt;Three things.&lt;/p&gt;

&lt;p&gt;First, write the tenant scope into your query helpers before you have a second tenant, because retrofitting it after the fact means auditing every route under deadline pressure.&lt;/p&gt;

&lt;p&gt;Second, custom domains are a product feature with a support cost, not a technical checkbox. Budget for the human side.&lt;/p&gt;

&lt;p&gt;Third, when you build on top of another API, treat it as a sync source and own your copy of the data. The day that provider has an outage, your customers should still be able to log in and look at yesterday's calls.&lt;/p&gt;

&lt;p&gt;None of this is glamorous. But the boring, disciplined version of multi-tenancy is the entire difference between a demo and something an agency will put their own name on. If you want to see where it ended up, it lives at &lt;a href="https://voice-dash.com" rel="noopener noreferrer"&gt;voice-dash.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are building something similar and hit the same walls, I would genuinely like to compare notes.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>saas</category>
      <category>typescript</category>
    </item>
    <item>
      <title>How did I become a Freelance Game Developer in Pakistan?</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sun, 31 Jul 2022 14:49:44 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/how-did-i-become-a-freelance-game-developer-in-pakistan-5fig</link>
      <guid>https://dev.to/nabeelbaghoor/how-did-i-become-a-freelance-game-developer-in-pakistan-5fig</guid>
      <description>&lt;h2&gt;
  
  
  Starting my Journey
&lt;/h2&gt;

&lt;p&gt;Being from a country like Pakistan, choosing to become a game developer was not that easy. I started learning Unity almost four years ago when I just started university. At that time, the game development industry in Pakistan was completely different. People didn't even know much about famous game engines like &lt;a href="https://unity.com/"&gt;Unity&lt;/a&gt; and &lt;a href="https://www.unrealengine.com/"&gt;Unreal&lt;/a&gt;. There were companies that were making games but it was not common among students to pursue such a career. There were no communities or people from whom I can learn. But yes, I had a golden opportunity because my brother was a game developer. Basically, he was the one who introduced me to this world. &lt;/p&gt;




&lt;h2&gt;
  
  
  Being a Professional Game Developer
&lt;/h2&gt;

&lt;p&gt;I became a professional Unity developer when I was 19. It was the start of my third semester and I was working on a personal project with my cousin who was a Voxel artist. While working on my own game project, instead of following tutorials, was the time when I actually felt power in my hands through which I can put my creativity into a game. This is when I decided to pursue game development as my career. Soon, I started getting freelance projects from my brother due to my experience. This was something new for me as I was earning as well. Then I started working actively on my freelance platforms and after working with several small clients, I got a great one from the UK. &lt;/p&gt;




&lt;h2&gt;
  
  
  GameTrain and DVC Shoot
&lt;/h2&gt;

&lt;p&gt;Being passionate about game development, and earning a handsome amount as a freelance game developer, I met &lt;a href="https://www.gametrain.org/"&gt;GameTrain&lt;/a&gt;. They were just taking a start with a great initiative of bridging the gap between academia and the game development industry in Pakistan. They chose me as their Brand Ambassador and I got featured in their DVC Shoot and an Advertisement. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://www.linkedin.com/posts/nabeel-hassan-9b0298137_gametrain-swag-dvcshoot-activity-6843627129054224384-A2T9?utm_source=linkedin_share&amp;amp;utm_medium=member_desktop_web"&gt;GameTrain Brand Ambassador Swag&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/posts/nabeel-hassan-9b0298137_gametrain-swag-dvcshoot-activity-6843627129054224384-A2T9?utm_source=linkedin_share&amp;amp;utm_medium=member_desktop_web"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--1oPRKX0O--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hxb7gnermq5o6h4f01v6.jpg" alt="GameTrain Brand Ambassador Swag" width="800" height="599"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Getting Featured on a Podcast
&lt;/h2&gt;

&lt;p&gt;But this was just a start because soon, a famous Pakistani Youtuber, &lt;strong&gt;&lt;a href="https://www.youtube.com/c/mooroosicity"&gt;Taimoor Salahuddin aka Mooroo&lt;/a&gt;&lt;/strong&gt; reached out, and I went for a Podcast with him along with three more people.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The title of Podcast was &lt;a href="https://youtu.be/XnOtg5d-dts?t=158"&gt;Game Development in Pakistan&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://youtu.be/XnOtg5d-dts?t=158"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--VYHJhUkl--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3joycq3z6jdw0wo07hf2.png" alt="Picture from Podcast" width="880" height="495"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Winning ICPC Programming Competition
&lt;/h2&gt;

&lt;p&gt;This was the time when I started becoming famous as a freelance game developer in Pakistan. I started getting more and more clients and offers through my LinkedIn and Facebook profiles. But, this was not just due to that podcast. There was something else as well. Basically, just before meeting GameTrain, I won ACM's ICPC Asia Topi Regional Programming Competition with my team, OVERLOADS and we got qualified for World Finals to represent our country.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://www.linkedin.com/posts/nabeel-hassan-9b0298137_woohoo-we-won-the-icpc-international-activity-6829866129817653248-xb3x?utm_source=linkedin_share&amp;amp;utm_medium=member_desktop_web"&gt;Team OVERLOADS&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/posts/nabeel-hassan-9b0298137_woohoo-we-won-the-icpc-international-activity-6829866129817653248-xb3x?utm_source=linkedin_share&amp;amp;utm_medium=member_desktop_web"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--2Al9Kzrt--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/abjpq9gd8p6hr7p0pba3.jpg" alt="ICPC Winners" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Graduation and Starting my Career
&lt;/h2&gt;

&lt;p&gt;Now, when I have graduated, I adopted my career to be a full-time freelance Unity game developer. When I was in university, I was working on &lt;a href="https://www.fiverr.com/"&gt;Fiverr&lt;/a&gt;. But now I have started working on &lt;a href="https://upwork.com/"&gt;Upwork &lt;/a&gt; which has better clients with better rates. Other than that, I am applying on &lt;a href="https://www.toptal.com/"&gt;Toptal&lt;/a&gt; as well but it has quite a long and difficult process to be part of their network. After all that hard work, this is really a great opportunity. Will write more about my experience later on when I will be part of it. You can see other Unity developers who are part of Toptal &lt;a href="https://www.toptal.com/unity-unity3d"&gt;here&lt;/a&gt;. &lt;/p&gt;




&lt;h2&gt;
  
  
  Follow my Journey
&lt;/h2&gt;

&lt;p&gt;If you liked this post, you might like those that will follow.&lt;/p&gt;

&lt;p&gt;See you next time! 🙌&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
