Skip to main content
Chatbots

How to build a multi-tenant AI chat widget in Next.js

Published Aug 13, 20268 min read

How to build a multi-tenant AI chat widget in Next.js

Most "build an AI chatbot" tutorials get you to one bot, one account, one set of documents. That's a demo. The moment you want to sell the same widget to more than one client, you need a different architecture, not a bigger version of the same one.

Why "one chatbot per account" doesn't scale

A single-tenant chat app ties documents, conversation history, and billing to user_id. That's fine when the user and the customer are the same person. It breaks the moment you want to run the widget for someone else's business, because now you need per-client branding, per-client document isolation, and a way for that client's website visitors, who have no account at all, to talk to it.

The fix is a bots table sitting between your accounts and your documents.

The data model

create table bots (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references profiles(id) not null,
  name text not null,
  public_key text not null unique,
  allowed_domains text[] not null default '{}',
  display_name text,
  welcome_message text,
  primary_color text,
  system_prompt text,
  is_active boolean not null default true
);

The public_key isn't secret, it ships in plain HTML on the client's website, same as a Stripe publishable key. Protection comes from allowed_domains and rate limits, never from hiding the key. A bot with an empty allowed_domains array should refuse to run, secure by default rather than fail-open.

Documents get a bot_id column instead of (or alongside) user_id, so retrieval can be scoped to exactly one bot's knowledge base:

alter table documents add column bot_id uuid references bots(id);

Scoping RAG retrieval per bot

The retrieval query changes from "find chunks for this account" to "find chunks for this bot":

const { data: botDocuments } = await supabase
  .from("documents")
  .select("id")
  .eq("bot_id", bot.id)
  .eq("status", "completed");

const chunks = await retrieveRelevantChunks(
  message,
  botDocuments.map((d) => d.id),
  topK
);

One detail that trips people up: the widget's chat endpoint is called by an anonymous visitor with no Supabase session, so it has to use a service-role client, not the session-bound client your authenticated dashboard uses. If your retrieval helper only accepts a session client, add an optional client parameter rather than duplicating the function.

The embed: iframe, not injected DOM

The tempting shortcut is a loader script that injects chat HTML directly into the host page. Don't. The host site's CSS will fight your widget, and yours can break their layout, and you're shipping onto hundreds of sites you'll never see. An iframe gives full CSS and JS isolation in both directions, which is why Intercom, Crisp, and Chatbase all do it this way.

<script src="https://yourapp.com/widget.js" data-bot-key="pk_abc123" async></script>

widget.js is a small loader: read the bot key, inject a fixed-position iframe pointing at /widget/[publicKey], listen for postMessage from the iframe to resize between a launcher bubble and a full chat panel.

Rate limits and credit caps, because the endpoint is public

This is the part single-tenant tutorials skip entirely, because a login-gated endpoint doesn't need it. A public, unauthenticated chat endpoint needs several layers:

  1. Per-visitor rate limit — stops one person spamming a single bot
  2. Per-bot hourly cap — a blast-radius backstop if something bypasses the first layer
  3. Owner credit-balance check — the actual money boundary; the bot bills its owner's account, and if they're out of credits the widget should degrade to a polite message, not a broken error, while notifying the owner

None of this needs to be complicated. A sliding-window counter keyed by visitor ID or bot ID, checked before every generation, covers most of it.

Lead capture, the part that makes it worth paying for

Answering questions is table stakes. The reason a business pays monthly for this is the lead: extract an email or phone number from the visitor's message, write it to a leads table scoped by bot_id, and email the bot's owner the moment it happens. That last step matters more than it sounds, a lead sitting unread in a dashboard is not a captured lead.

Putting it together

Bots table with per-bot RLS, documents scoped by bot_id, RAG retrieval filtered per bot, a sandboxed iframe embed, rate limits plus a real credit check, and lead capture with owner notifications, that's the full shape of a resellable multi-tenant AI chat widget, not a bigger single-tenant chatbot.

This is exactly what Deskly ships pre-built, if you'd rather buy the working version than assemble it from scratch. For the business case on why this architecture is worth building, see our Chatbase alternative breakdown.

Newsletter

Get the BoilerlyKit newsletter

Practical Next.js SaaS launch tips, delivered when we ship something worth sharing.

We respect your inbox. See our privacy policy.