Multi-tenant Next.js at any scale

Michele Riva

Senior Software Engineer @ViacomCBS

Book Author @Packt

Michele Riva

Senior Software Engineer @ViacomCBS

Book Author @Packt

Michele Riva

Senior Software Engineer @ViacomCBS

Book Author @Packt

What is multi-tenancy?

Single tenant

Separate Databases

Single Database, Separate Schemas

Instance Replication via Kubernetes

Kubernetes Cluster

Pod

Pod

Pod

Pod

Pod

Reverse Proxy

Cost reduction

Easier monitoring

Monorepo friendly

Reuse components and scripts

One pipeline to rule 'em all

Advantages of a multi-tenant architecture

Why Next.js?

Ease of use

We all love React

Built-in features

Easy onboarding

Great community

The dream

Next.js app

node_modules/

public/

components/

pages/

comedycentral/

paramount/

nickelodeon/

mtv/

The problem

Next.js currently supports only one website at the time

Possible approach #1

Instance replication + reverse proxy

Container

Reverse Proxy

Possible approach #2

Instance replication via Kubernetes

Kubernetes Cluster

Pod

Pod

Pod

Pod

Reverse Proxy

Possible approach #3

Move the reverse-proxy at the web server level

Krabs

Express.js middleware for multi-tenant Next.js applications

github.com/micheleriva/krabs

Next.js app

node_modules/

public/

components/

pages/

comedycentral/

paramount/

nickelodeon/

mtv/

server.js

.krabs.config.js

const express = require('express');
const next = require('next');
const krabs = require('krabs').default;
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });

async function main() {
  try {
    await app.prepare();

    const handle = app.getRequestHandler();
    const server = express();

    server
      .get('*', (req, res) => krabs(req, res, handle, app))
      .listen(3000, () => console.log('server ready'));
  } catch (err) {
    console.log(err.stack);
  }
}

main();

server.js

module.exports = {
  tenants: [
    {
      name: 'comedycentral',
      domains: [
        {
          development: /dev\.[a-z]*\.local\.comedycentral\.com/, // Regex supported!
          stage: 'stage.comedycentral.com',
          production: 'comedycentral.com',
        },
      ],
    },
    {
      name: 'paramount',
      domains: [
        {
          development: 'local.paramountnetwork.com',
          stage: 'stage.paramountnetwork.com',
          production: /paramountnetwork\.[com|it|fr]/, // Regex supported!
        },
      ],
    },
  ],
  // ...
};

.krabs.config.js

Next.js app

pages/

comedycentral/

server.js

.krabs.config.js

index.js

shows/

[show].js

paramount/

index.js

movies/

[movie].js

about.js

Next.js app

pages/

comedycentral/

server.js

.krabs.config.js

index.js

shows/

[show].js

paramount/

index.js

movies/

[movie].js

about.js

www.comedycentral.com

www.comedycentral.com/shows/southpark

Next.js app

pages/

comedycentral/

server.js

.krabs.config.js

index.js

shows/

[show].js

paramount/

index.js

movies/

[movie].js

about.js

www.comedycentral.com

www.comedycentral.com/shows/southpark

www.paramountnetwork.com

www.paramountnetwork.com/movies/titanic

Handling layouts using different libraries and technologies

import ComedyCentralLayout from '../layouts/comedycentral';
import ParamountLayout from '../layouts/paramount';
import NickelodeonLayout from '../layouts/nickelodeon';
import MtvLayout from '../layouts/mtv';

const layout = {
  comedycentral: ComedyCentralLayout,
  paramount: ParamountLayout,
  nickelodeon: NickelodeonLayout,
  mtv: MtvLayout
};

function App({ Component, pageProps }) {
  const TenantLayout = layout[pageProps.tenant];

  return (
    <TenantLayout>
      <Component {...pageProps} />
    </TenantLayout>
  );
}

App.getInitialProps = async ({ Component, ctx }) => {
  const tenant = ctx?.req?.tenant?.name;
  let pageProps = {};

  if (Component.getInitialProps) {
    pageProps = await Component.getInitialProps(ctx);
  }

  return {
    pageProps: { ...pageProps, tenant }
  };
};

export default App;

_app.js

import { ChakraProvider } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import Menu from '../Menu';

function Layout(props) {
  return (
    <ChakraProvider>
      <Box bg="cyan.50" minHeight="100vh">
        <Menu />
        <Box maxWidth="90vw" width={1200} margin="auto" pt={10}>
          {props.children}
        </Box>
      </Box>
    </ChakraProvider>
  );
}

export default Layout;

layouts/comedycentral/index.js

White-label component library

Comedy Central theme

Paramount theme

Nickelodeon theme

Mtv theme

const themes = {
  comedycentral: {
    background: black,
    color: white,
    // ...
  },
  paramount: {
    background: blue,
    color: white,
    // ...
  },
  nickelodeon: {
    background: orange,
    color: white,
    // ...
  },
  mtv: {
    background: grey,
    color: white,
    // ...
  },
}

export default themes;
import { ThemeProvider } from 'styled-components';
import themes from '../styles/themes';

function App({ Component, pageProps }) {
  const tenantTheme = themes[pageProps.tenant];

  return (
    <ThemeProvider theme={tenantTheme}>
      <Component {...pageProps} />
    </ThemeProvider>
  );
}

App.getInitialProps = async ({ Component, ctx }) => {
  const tenant = ctx?.req?.tenant?.name;
  let pageProps = {};

  if (Component.getInitialProps) {
    pageProps = await Component.getInitialProps(ctx);
  }

  return {
    pageProps: { ...pageProps, tenant }
  };
};

export default App;

_app.js

Speedy demo!

Downsides of using Krabs

- Needs a custom server

- Cannot be deployed to Vercel

- No SSG + ISR

- _document.js and _app.js are the same for all websites

Downsides of using Krabs

- Needs a custom server

- Cannot be deployed to Vercel

- No SSG + ISR

- _document.js and _app.js are the same for all websites

How does Krabs scale?

Just like a common Express.js server.

What are Krabs use cases?

creative agencies

consultancy companies

big corporates

SASS products

Any alternative?

// Custom server

const fastify = require('fastify')()

fastify
  .register(require('fastify-nextjs'))
  .after(() => {
    fastify.next('/hello')
  })

fastify.listen(3000, err => {
  if (err) throw err
  console.log('Server listening on http://localhost:3000')
})
// /pages/hello.js

export default () => <div>hello world</div>
const fastify = require('fastify')({
  trustProxy: true
})

fastify
  .register(require('fastify-nextjs'))
  .after(() => {
    fastify.next('/hello', (app, req, reply) => {
      const { hostname } = req;

      if (hostname === 'paramountnetwork.com') {
        app.render(req.raw, reply.raw, '/paramount/hello', req.query, {})
      }
      
      else if (hostname === 'mtv.com') {
        app.render(req.raw, reply.raw, '/mtv/hello', req.query, {})
      }

      else {
        app.render(req.raw, reply.raw, '/_error', req.query, {})
      }

    })
  })

fastify.listen(3000, err => {
  if (err) throw err
  console.log('Server listening on http://localhost:3000')
})

Some final tips

1) Keep it simple

Do you really need it?

2) Think template-based

3) Make it stateless

@MicheleRiva

@mitch_sleva

@MicheleRivaCode

/in/MicheleRiva95

www.micheleriva.it

https://rwnjs.com/order/amazon

https://rwnjs.com/order/packt

Multi-tenant Next.js at any scale

By Michele Riva

Multi-tenant Next.js at any scale

Slides for my Codemotion talk about multi-tenancy in Next.js

  • 1,589