This content originally appeared on HackerNoon and was authored by Paul Towers
In any multi-tenant SaaS product, the moment a new user signs up is more than just adding a row in a “users” table. That user represents a new team, a new data boundary, and a new permission structure.
We made a conscious decision early: treat account creation as the root of the architecture.
Instead of bolting on organizations after user creation or storing org data as just another field on the user we made the account the primary object. Users belong to accounts. Data belongs to accounts. Permissions derive from accounts.
That framing influenced every piece of our backend from schema design to billing logic to RBAC.
Here’s how we built it.
Rethinking Registration: Account Before User
Most SaaS onboarding logic looks like this:
POST /signup → Create user → Later: create org / assign team
But in multi-tenant SaaS, that order leads to subtle problems…data without a home, permissions in limbo, and fragile assumptions throughout your app.
So we started with:
POST /signup → Create account → Create user inside account → Done
This change might seem small, but it shaped everything about our architecture going forward.
The code looks something like this:
export async function registerAccount(payload: RegistrationDTO) {
return withTransaction(async (session) => {
const account = await AccountModel.create([{
name: payload.company,
active: true
}], { session });
const user = await UserModel.create([{
accountId: account[0]._id,
email: payload.email,
role: 'admin'
}], { session });
return { account: account[0], user: user[0] };
});
}
By tying the user directly to the account from day one and wrapping it in a transaction we avoided a long list of edge cases that otherwise creep in fast.
Why It Matters: Data Belongs to Tenants
Here’s what we learned the hard way: data isn’t global, it's tenant-scoped.
Every query, permission check, feature gate, and UI control in your app will behave differently depending on who the user is and what account they belong to.
So we made one rule:
Everything important carries an
accountId
.
That includes:
- Users
- Battlecards
- Permissions
- Subscriptions
- Audit logs
- Feature toggles
Keep Transactions Tight
When building the account registration flow, one of our key principles was separating validation from persistence and being intentional about where we used database transactions.
Here’s what that looks like in practice:
\
await ensureEmailFree(payload.email);
await ensureCompanyFree(payload.company);
return withTransaction(async (session) => {
// Only the critical writes happen here
const account = await Account.create([{ name: payload.company }], { session });
const user = await User.create([{ email: payload.email, accountId: account[0]._id }], { session });
return { account: account[0], user: user[0] };
});
Why? So the transaction stays small and fast, easier to scale, fewer lock issues, and easier to recover from failures.
Bootstrap Admins, Automatically
The first user inside any account becomes its admin. No special logic, no manual flag-setting. It’s built right into the flow.
That first user can:
- Invite their team
- Set roles and permissions
- Configure billing
- Transfer admin rights if they leave
This gave us a clean, hands-off onboarding path that scaled well even in the early days.
Account-Scoped Roles that Grow With You
We didn’t want to overbuild a role system but we needed enough structure to cover 90% of real-world SaaS use cases.
\
const ROLE_HIERARCHY = {
admin: ['admin', 'manager', 'user'],
manager: ['manager', 'user'],
user: ['user']
};
function hasPermission(userRole, requiredRole) {
return ROLE_HIERARCHY[userRole]?.includes(requiredRole) || false;
}
We scoped roles to accounts and used simple inheritance. It’s not fancy, but it works and it’s dead easy to refactor later into a more granular policy model if needed.
Subscriptions from Day One
We gave every account a subscription object at creation, even during trial. That meant feature flags, usage limits, and upgrade prompts were tied directly to the account from the beginning.
const defaultSubscription = {
tier: 'trial',
status: 'active',
limits: {
maxUsers: 5,
maxBattlecards: 10,
aiBattlecardGeneration: true
}
};
We use this in middleware to gate features:
if (!account.subscription.limits.aiBattlecardGeneration) {
return res.status(402).json({ error: 'Upgrade required' });
}
Namespaces, Uniqueness, and Isolation
Some things are globally unique. Some are tenant-local. We drew the line like this:
| Field | Scope | Why |
|----|----|----|
| email
| Global | One user = one email |
| account.name
| Global | Used in URLs and billing |
| Other data | Tenant | Scoped by accountId
|
What We Got Right (And What You Can Steal)
This small shift, making account creation the center of onboarding, paid off immediately and continues to save us time today.
Short-Term Wins:
- Clean data isolation
- Role-based access from day one
- Feature flag logic that actually works
- Fully functional trial flow
Long-Term Payoffs:
- Queries are easy to scope and optimize
- Billing and permissions are decoupled
- No weird migration needed when you “add teams later”
- Safer defaults everywhere
Final Thoughts: Start With the Right Mental Model
The registration page might look simple, but it kicks off the most important structure in your SaaS: the tenant.
So build like it matters.
Start with the account. Attach everything else to it. Enforce boundaries early. Everything else, roles, permissions, billing, falls into place more naturally.
Instead of thinking “user-first” and instead thinking “account-first” gave us an architecture we could trust… and build on.
This content originally appeared on HackerNoon and was authored by Paul Towers

Paul Towers | Sciencx (2025-07-09T07:32:18+00:00) Stop Thinking “User Signup”. Start Thinking “New Tenant”. Retrieved from https://www.scien.cx/2025/07/09/stop-thinking-user-signup-start-thinking-new-tenant/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.