Build B2B2C SaaS Apps with Portals

The boilerplate to build B2B2C SaaS apps with SaasRock portals.

  • Alexandro Martínez
    by Alexandro Martínez
    3 months ago
  • Watch the introduction video or watch the Building a Directory Website Builder series.

    1/4) Definitions

    B2B vs B2B2B or B2B2C

    In a traditional B2B SaaS app, you have two type of users:

    • Your team: Admins

    • Your customers: Tenants (or Accounts) with their Members (or Users)

    In a B2B2C app, a third person is introduced:

    • The consumer: Your tenants’ customers

    What is a Portal?

    In short, the app for the Consumer (PortalUser in the database), the end-user app.

    Some examples could be:

    • Customer Service apps: Help Desk, Chat apps…

    • E-commerce Platforms: Online Stores, Marketplaces…

    • Booking Systems: Restaurant Reservations, Appointment Scheduling…

    • Membership Sites: Access to videos, documents, images…

    • Content Platforms: Blogs, Directories…

    The default Portal project is empty. This means you still need to build your platform from scratch, but the infrastructure behind is is there:

    • Users: Simple portal users management

    • Analytics: Track portal page views (enterprise-only)

    • Pricing: Custom portal products with Stripe Connect

    • Settings: Title, Subdomain, Logo, icon, favicon, theme, SEO…

    • Domains: Custom domain

    • Custom properties: Metadata dynamic JSON properties

    Pay your Tenants using Stripe Connect

    In each Portal, you or your customers can connect their Stripe account and create Products with Prices. This way when their PortalUsers register, they can subscribe or buy to their products linking their connected account, so they get paid eventually.

    You can define your fee application_fee_percent or application_fee_amountat PortalStripe.server.ts.

    2/4) Portals Architecture

    There are 2 projects:

    Server 1 — Base server: Marketing, Admin, and Tenant dashboard

    Your SaasRock server: /admin, /app/:tenant pages, pricing page, etc…

    Server 2 — Portal server: The Consumer Application

    By default, the portals are accesible using the unique portal subdomain + the portal server url.

    In this example, the portal application is hosted at So I created a wildcard certificate for *

    Certificate issuing depends on your hosting provider, in this case I’m using


    Every request to a portal, needs to identify the corresponding Portal. So in order to improve performance, it’s cached. Every time the base server updates a portal, the API endpoint /api/cache is called to the portal server.


    Similarly, but the the way around, the portal server calls /api/analytics/page-views to the base server. This is way the base server needs to know the portal server URL and viceversa.


    3/4) Getting Started with Portals

    By default, Portals is disabled, you need to turn it on along with its sub-features in the appConfiguration.db.server.ts file:

    const conf: AppConfiguration = {    
        portals: {
    -     enabled: false,
    +     enabled: true,
          forTenants: true,
          pricing: true,
          analytics: false,
          domains: undefined,
          metadata: [],

    Set the .env variable PORTAL_SERVER_URL to http://localhost:3001, which is the default port (but you can change it at vite.config.ts).

    Custom Domains

    Take a look at my portal domains app configuration:

    const conf: AppConfiguration = {    
          domains: {
            enabled: true,
            provider: "fly",
            portalAppId: "saasrock-portal",
            records: {
              A: "",
              AAAA: "2a09:8280:1::31:5dc2:0",

    This highly depends on my hosting provider, currently only is supported.

    I took the A and AAAA record values from my default certificate:

    When your customer configures a custom domain, they will be given instructions on what records should they add to their DNS.

    This way, the portal is now accessible using a custom domain, and not just using the default subdomain at {portal}.{portalServerUrl}.

    Yes, I bought for this article.

    4/4) Building the Portal

    I have a few suggestions for you when working with portals:

    1) Add a git upstream to the Portal

    In the Portal project, run:

    git remote add base{YOUR-SAASROCK-PROJECT}
    git remote set-url --push base no_push
    git remote add upstream
    git remote set-url --push upstream no_push

    This way when you modify shared components (say InputText), it will be pulled from the base server project to the portal server project.

    And on the Base project, set up the upstream to get saasrock updates:

    git remote add upstream
    git remote set-url --push upstream no_push

    Or if you're a SaasRock Core edition user:

    git remote add upstream
    git remote set-url --push upstream no_push

    2) Only modify the schema on the Base Server

    If you’re building a Directory Listing Builder like me, where the Portals are the Directories, you may want to know where to start.

    3) Add your Feature Module folder on the Base Server

    If you’re building a Directory Listing Builder like me, where the Portals are the Directories, you may want to know where to start. I’d create a app/modules/directoryLisiting folder to keep all my DTOs, db calls, services, and utils.

    4) Build the portal management on the Base Server

    I’d then create the management of listings at /app/:tenant/portals/:portal/listings. Once completed, push to the base server repository, go back to the Portals server and pull from the upstream.

    5) Pull from the upstream and Fix merge conflicts

    You will definlety get conflicts because the nature of the two projects (Base vs Portal) is very different.

    The Base project has /admin, /app/:tenant, and tons of more routes that the Portal project does not. So every time you build/fix/change something on the base project, you’d need to meticulously accept files. For example:

    • You made a change to app/routes/app.$tenant.tsx: It doesn’t exists on the portal project, so confirm Delete file.

    • You added a module: You shouldn’t have issues.

    • You changed the landing page: You should keep the local changes (your portal landing page, instead of overwriting it with the base project landing page).

    • You added i18n translation keys: It could be helpful, so accept incoming changes unless the translation key collides with portal-specifics.

    6) Update your database + Prisma client on the Portal Server

    When you’ve made schema changes on the Base Server, you should update your database again with:

    npx prisma db push

    7) Ignore unnecesary database models

    And if the models pulled are NOT going to be used on the Portal Server, you can flag them with the Prisma @@ignore attribute so it’s not added to the generated client. Actually, this was my way of not using the Base Server models: User, Tenant, AppConfiguration… and only keep Portal specific models: Portal, PortalUser, PortalSubscriptionProduct

    8) Leverage Portals metadata: Dynamic JSON properties

    In your app configuration, you can set which dynamic properties would a Portal have, for example:

    const conf: AppConfiguration = {
        portals: {
          enabled: true,
          metadata: [
    +       {
    +         name: "layout",
    +         title: "Layout",
    +         type: "select",
    +         required: true,
    +         defaultValue: "hero",
    +         options: [
    +            { name: "Classic", value: "classic" },
    +            { name: "Hero", value: "hero" },
    +         ]
    +       }

    This gives you a great starting point to add portal-specific logic and UI.

    Notice how instead of “Portals”, here they’re “Directories”. This is because I changed the i18n translation keys:

    "models": {
      "portal": {
    -    "object": "Portal",
    -    "plural": "Portals",
    +    "object": "Directory",
    +    "plural": "Directories",

    9) Use your Feature Module on the Portal Server

    Finally, you can start building “from scratch” your B2B2C portal. Watch my Building a SaaS - Directory Listing Builder youtube series to see how I'm doing it!


    For dynamic JSON metadata fields, I’m now using the Json property, which is not supported in SQLite:

    You’d need to change Json? with String? and hope for the best. Just keep in mind that you’d need to parse the JSON manually:

    • When reading: JSON.parse(portal.metadata ?? “{}”)

    • When creating/updating: JSON.stringify(propertiesValues)

    Known Errors

    When working with portals you might encounter one of these errors:

    • When migrating/pushing the database with npx prisma: “Error: insert or update on table "AnalyticsUniqueVisitor" violates foreign key constraint "AnalyticsUniqueVisitor_portalId_fkey”: run this in your database: DELETE FROM "AnalyticsUniqueVisitor" WHERE "portalId" is not null and "portalId" not in (select "id" from "Portal") and try again.

    Conclusion: Portals are a Work in Progress

    I can think of 100 things that I could add to Portals, but these come to my mind without too much thought:

    • Transactional emails: Portal creators should be able to provide their Postmark or Resend API keys, and edit Welcome, Reset password, and other transactional emails. Currently Portal Users don’t get emails.

    • Email marketing: Similar to the SaasRock Enterprise email marketing feature, to allow portal creators to have a mini CRM and send emails to their Portal Users.

    • Vercel deployments: When Remix + Vite came out, my Vercel deployments stopped working. They’ve now fixed it, but I haven’t found the time to support Vercel again.

    • Affiliates and Referrals: Since saasrock supports Rewardful for affiliate commisions, it could be easily ported into Portals, so your customers add their referral program and pay their affiliates.

    • Google and/or GitHub SSO: Allow portal owners to submit their credentials for their customers to register/logic using Google or GitHub.

    Let me know what you think on the Discord server!

    We respect your privacy.

    TLDR: We use cookies for language selection, theme, and analytics. Learn more.