DEV Community

Cover image for Step by step: Multi-Tenant App with Next.js
iskurbanov
iskurbanov

Posted on

Step by step: Multi-Tenant App with Next.js

Next.js now allows you to easily create a multi-tenant application using subdomains. This enables you to create web apps like Linktree, Super.so, and other apps where a user gets their own webpage for example.

Before we start, here are some additional resources:

Step 1: Create a blank Next.js app

npx create-next-app

You will be asked if you want Typescript, ESLint and other options. Hit yes for everything.

Once the app is created, open it in your code editor (VSCode).

Step 2: Deploy app to Vercel

You can do inside VSCode by opening the terminal (Command + J on Mac).

Install the Vercel CLI:
npm i -g vercel

Once that is done, go ahead and run:
vercel --prod to deploy it to production

Click on the deployment link to end up in the Vercel dashboard.

Step 3: Setup your domain

In order to use the subdomain feature in Vercel, you will need to setup your own wildcard domain. If you are a developer, you should have plenty of unused domains ;)

Mine is called buildwithnext.com

I am using Namecheap so here is how you do it:

In the Vercel dashboard add your wildcard domain
Adding a wildcard domain in Vercel

Then log into your Namecheap account and add the following Nameserver DNS urls

Set up custom Nameserver DNS in Namecheap

When you add a wildcard domain, Vercel will automatically populate all the other ones for you.

Step 4: Setting up routing in Next.js

Add a new folder in the pages folder called _sites and then another one in there called [site]. Then add an index.tsx file in the [site] folder.

Your new folder structure should now look like this:

pages
└───api
└───sites
│      │
│    [site]
│      │  index.tsx
package.json
etc
Enter fullscreen mode Exit fullscreen mode

Inside the index.tsx file, add the following code:

import { useRouter } from "next/router";
// we will create these in the next step
import { getHostnameDataBySubdomain, getSubdomainPaths } from "@/lib/db";

// Our types for the site data
export interface Props {
  name: String
  description: String
  subdomain: String
  customDomain: String
}

export default function Index(props: Props) {
  const router = useRouter()

  if (router.isFallback) {
    return (
      <>
        <p>
          Loading...
        </p>
      </>
    )
  }

  return (
    <>
      <h1>
        {props.name}
      </h1>
    </>
  )
}

// Getting the paths for all the subdomains in our database
export async function getStaticPaths() {
  const paths = await getSubdomainPaths()

  return {
    paths,
    fallback: true
  }
}

// Getting data to display on each custom subdomain
export async function getStaticProps({ params: { site } }) {
  const sites = await getHostnameDataBySubdomain(site)

  return {
    props: sites,
    revalidate: 3600
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Adding Middleware and mock data

A middleware file allows us to intercept the api calls to our backend and do something with it. In this case we are using the middleware to determine what hostname the api call is being made for and display the correct data.

Let's create a middleware.ts file in the root of our project directory

pages
└───api
└───sites
│      │
│    [site]
│      │  index.tsx
middleware.ts
package.json
etc
Enter fullscreen mode Exit fullscreen mode

Inside of the middleware.ts file, add this code:

import { NextRequest, NextResponse } from 'next/server'
import { getHostnameDataOrDefault } from './lib/db'

export const config = {
  matcher: ['/', '/about', '/_sites/:path'],
}

export default async function middleware(req: NextRequest) {
  const url = req.nextUrl

  // Get hostname (e.g. vercel.com, test.vercel.app, etc.)
  const hostname = req.headers.get('host')

  // If localhost, assign the host value manually
  // If prod, get the custom domain/subdomain value by removing the root URL
  // (in the case of "subdomain-3.localhost:3000", "localhost:3000" is the root URL)
  // process.env.NODE_ENV === "production" indicates that the app is deployed to a production environment
  // process.env.VERCEL === "1" indicates that the app is deployed on Vercel
  const currentHost =
    process.env.NODE_ENV === "production" && process.env.VERCEL === "1"
      ? hostname
        .replace(`.buildwithnext.com`, "")
      : hostname.replace(`.localhost:3000`, "");



  const data = await getHostnameDataOrDefault(currentHost)

  // Prevent security issues – users should not be able to canonically access
  // the pages/sites folder and its respective contents.
  if (url.pathname.startsWith(`/_sites`)) {
    url.pathname = `/404`
  } else {
    // console.log('URL 2', req.nextUrl.href)
    // rewrite to the current subdomain under the pages/sites folder
    url.pathname = `/_sites/${data.subdomain}${url.pathname}`
  }

  return NextResponse.rewrite(url)
}
Enter fullscreen mode Exit fullscreen mode

There is a lot happening here but I tried to add more comments to clear things up. It might look scary at first but try going line by line and reading the comments. It will eventually make sense.

Now let's go ahead and add a lib folder in the root of our project directory and a db.ts file inside of it.

lib
└───db.ts
pages
└───api
└───sites
│      │
│    [site]
│      │  index.tsx
middleware.ts
package.json
etc
Enter fullscreen mode Exit fullscreen mode

Inside of the db.ts file, add the following code:

// Dummy data to be replaced with your database
const hostnamesDB = [
  {
    name: 'This is Site 1',
    description: 'Subdomain + custom domain',
    subdomain: 'test1',
    customDomain: 'custom-domain-1.com',
    // Default subdomain for Preview deployments and for local development
    defaultForPreview: true,
  },
  {
    name: 'This is Site 2',
    description: 'Subdomain only',
    subdomain: 'test2',
  },
  {
    name: 'This is Site 3',
    description: 'Subdomain only',
    subdomain: 'test3',
  },
]
const DEFAULT_HOST = hostnamesDB.find((h) => h.defaultForPreview)

/**
 * Returns the data of the hostname based on its subdomain or custom domain
 * or the default host if there's no match.
 *
 * This method is used by middleware.ts
 */
export async function getHostnameDataOrDefault(
  subdomainOrCustomDomain?: string
) {
  if (!subdomainOrCustomDomain) return DEFAULT_HOST

  // check if site is a custom domain or a subdomain
  const customDomain = subdomainOrCustomDomain.includes('.')

  // fetch data from mock database using the site value as the key
  return (
    hostnamesDB.find((item) =>
      customDomain
        ? item.customDomain === subdomainOrCustomDomain
        : item.subdomain === subdomainOrCustomDomain
    ) ?? DEFAULT_HOST
  )
}

/**
 * Returns the data of the hostname based on its subdomain.
 *
 * This method is used by pages under middleware.ts
 */
export async function getHostnameDataBySubdomain(subdomain: string) {
  return hostnamesDB.find((item) => item.subdomain === subdomain)
}

/**
 * Returns the paths for `getStaticPaths` based on the subdomain of every
 * available hostname.
 */
export async function getSubdomainPaths() {
  // get all sites that have subdomains set up
  const subdomains = hostnamesDB.filter((item) => item.subdomain)

  // build paths for each of the sites in the previous two lists
  return subdomains.map((item) => {
    return { params: { site: item.subdomain } }
  })
}

export default hostnamesDB
Enter fullscreen mode Exit fullscreen mode

This one is pretty self explanatory. We basically have some dummy data that we use to get the subdomain and the data to display on the frontend.

Again, go line by line and read the comments.

Step 6: Test the app and deploy

We can now run npm run dev to test the app.

Navigate to localhost:3000 (make sure you are running on localhost:3000 because that's what we put in the middleware.ts file).

Now you can try to go to test2.localhost:3000. The content should change to This is Site 2

It should work the same way if you deploy your application now! vercel --prod

Next.js app running a multi-tenant architecture

Step 7: Next Steps

You can now replace the dummy data with a database such as PlanetScale and use the Prisma ORM to have your users setup their own subdomain and add data to their sites! I'll be adding more tutorials on how to do that so stay tuned.

I hope this article was helpful!

Top comments (12)

Collapse
 
jetroolowole profile image
Jetro Olowole

My challenge is to host this with AWS rather than Vercel

Collapse
 
amal_shyjo_edc240e5918113 profile image
Amal Shyjo

Hi @iskurbanov
I’m following your guide on implementing a multi-tenant architecture for one of our existing products. However, we have a situation where our client already has separate hosting for domain.com and app.domain.com. Could you let me know if using wildcard subdomains might interfere with these existing domains?
Thanks!

Collapse
 
abdmun8 profile image
Abdul Munim

Thanks, @iskurbanov , this post helps me a lot.
I have a multi-tenancy SSG app, how can I add i18n? Can you give me some advice?

Collapse
 
barhouum7 profile image
IboTech

Same question here, I've also implemented a multi-tenant app with i18n next-intl and it works but I still facing some conflict problems in the app structure, cuz I need to set up localization for the dashboard and landing page only without affecting the dynamic route like [domain]

Collapse
 
tejasgk profile image
Tejas

couple of questions

  • is the approach same for Next 13 App dir too
  • what if I only want to test it locally
Collapse
 
br4mber profile image
Amin • Edited

I've implemented analagous logic with the App dir and it's working perfectly well! If fact your file structure in the App dir will look exactly the same, just create the "site" logic in a page.tsx file instead of index.tsx.

Image description

I also added the following to route both my domain and localhost to another folder such that I could work with that instead of the root domain defualting to the DEFAULT_HOST like in this tutorial. Helped with running locally and debugging!

// rewrite root application to `/home` folder
  if (
    hostname === "localhost:3000" ||
    hostname === process.env.NEXT_PUBLIC_ROOT_DOMAIN
  ) {
    return NextResponse.rewrite(
      new URL(`/home${path === "/" ? "" : path}`, req.url),
    );
  }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
pierreatwork profile image
Pierre

Great article !
Do you have any idea on how to set up environnement variable for each sub-site ?
Thanks !

Collapse
 
konami99 profile image
Richard Chou

Will adding a prefix help?
For example key is domain:key

Collapse
 
nklido profile image
Nikos Klido

Would it be possible for customers to bring their own custom domain instead of the wildcard ?

Collapse
 
iskurbanov profile image
iskurbanov

Yes of course, checkout the Next.js platform starter kit for example of using custom domains: vercel.com/templates/next.js/platf...

Collapse
 
ditabdul profile image
Jojo

This is cool. Can u explain how can i using multitenancy and pathname both?
e.g.: user1.hosted-url.com/about

Thanks before

Collapse
 
br4mber profile image
Amin

Same logic :)

Add the following logic for user1.hosted-url.com/about

if (url.pathname.startsWith('/about')) {
    url.pathname = `/sites/${data?.subdomain}${url.pathname}`
Enter fullscreen mode Exit fullscreen mode