cd /news/artificial-intelligence/what-i-learned-building-a-white-labe… · home topics artificial-intelligence article
[ARTICLE · art-56474] src=dev.to ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

What I learned building a white-label, multi-tenant SaaS from scratch

A developer built VoiceDash, a white-label multi-tenant SaaS platform that allows agencies to resell AI voice agents under their own brand. The project revealed that multi-tenancy requires scoping every query by workspaceId to prevent data leaks, and custom domain support involves complex DNS and certificate handling. The stack includes Next.js, Prisma, Supabase, NextAuth, Stripe, Resend, Retell, and Vercel.

read5 min views1 publishedJul 12, 2026

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.

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.

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 main

on Vercel.

I picked boring tools everywhere I could, because the actual hard problems were not going to be about the framework.

Every SaaS tutorial shows you a User

table and a Post

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 Workspace

(the agency) owns Client

records, and both users and clients hang off the workspace.

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 workspaceId

, 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.

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 workspaceId

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:

const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const workspaceId = session.workspaceId;
if (!workspaceId) return NextResponse.json({ error: "No workspace" }, { status: 400 });

const agent = await prisma.agent.findFirst({
  where: { id: agentId, workspaceId },
});

Notice the findFirst

with both id

and workspaceId

. Not findUnique

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.

The whole selling point of white-label is that the agency's client visits portal.theiragency.com

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.

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.

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.

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?

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:

let apiKey = agent.apiKey;
if (!apiKey) {
  const integration = await prisma.integration.findFirst({
    where: { workspaceId, platform: "RETELL" },
  });
  apiKey = integration?.apiKey || null;
}
if (!apiKey) return NextResponse.json({ error: "No Retell API key found" }, { status: 400 });

Then syncing call history is just hitting Retell's list-calls

endpoint with that key, filtered to the agent's platform id, and upserting the results into my own Conversation

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.

Three things.

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.

Second, custom domains are a product feature with a support cost, not a technical checkbox. Budget for the human side.

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.

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 voice-dash.com.

If you are building something similar and hit the same walls, I would genuinely like to compare notes.

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @voicedash 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/what-i-learned-build…] indexed:0 read:5min 2026-07-12 ·