iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
👨‍👩‍👧‍👦

Running multiple services in Next.js without monorepo tools

に公開

There are often cases where you want to use the same repository in Next.js but run it as multiple separate applications.

In most cases, the method of using monorepo tools such as Turborepo or Nx is widely known. Even in the official Next.js documentation, monorepos are used for Multi-zone examples.

On the other hand, monorepos can be demanding and require a certain level of commitment, as there are places where they might not be supported or require extra care.

This time, I've come up with two lighter methods that don't use monorepo tools, so I'd like to summarize each of them.

  1. Separate apps by directory and switch them via environment variables (ENV).
  2. Adjust Webpack settings to resolve dependencies.

1. Separate Apps by Directory and Switch via ENV

This approach stays mostly within the Next.js "rails," so it doesn't require much commitment.
One disadvantage is that the directory is included in the URL, which might not look very good.

In this method, we use a directory structure like the following:

src
└── pages
    ├── _app.tsx
    ├── index.tsx  // Just redirects
    ├── mainapp    // Main app
    │   └── index.tsx
    └── subapp     // Sub app
        └── index.tsx

Only index is placed at the root level, and each app is separated.
While it is possible to place mainapp at the root, separating them into directories is safer for management purposes, so we will proceed with this method.
There may be cases where you only need to run the sub-app locally, in which case there's no need to confine the mainapp to a directory.

Adding Commands to package.json

First, add commands for starting the sub-app to package.json.

    "dev": "next dev",
    "dev:subapp": "APP_MODE=SUBAPP next dev -p 3002",

start and build are similar, but I will omit them here.

Enabling Separation in next.config

Next, configure the part that switches based on the environment variable in next.config.

// Settings to switch per environment
const appendConfig = (appMode) => {
  switch (appMode) {
    case "SUBAPP":
      return {
        // If distDir is the same, it will break when running locally
        distDir: ".next-subapp", 
        redirects: async () => ([{
          source: "/mainapp/:path*",
          destination: "/subapp",
          permanent: false,
        }, {
          source: "/api/mainapp/:path*",
          destination: "/error",
          permanent: false,
        }])
      }
    default:
      return {
        redirects: async () => ([{
          source: "/subapp/:path*",
          destination: "/mainapp",
          permanent: false,
        }, {
          source: "/api/subapp/:path*",
          destination: "/error",
          permanent: false,
        }])
      }
  }
}

// Example of a common config.
const baseAppConfig = {
  pageExtensions: ["tsx","ts", "page.tsx", "page.ts"]
}

module.exports = () => {
  const appMode = process.env.APP_MODE ?? ""
  const config = appendConfig(appMode)
  return {
    ...baseAppConfig,
    ...config
  }
}

What if you do it with middleware?

If you are using Next.js 12.2 or higher, redirection settings can also be handled in middleware.ts.

import { NextRequest, NextResponse } from "next/server"

export function middleware(request: NextRequest) {
  if (process.env.APP_MODE === "subapp") {
    if (request.nextUrl.pathname.startsWith("/mainapp") || request.nextUrl.pathname.startsWith("/api/mainapp")) {
      return NextResponse.redirect(new URL('/subapp', request.url))
    }
  }
  if (request.nextUrl.pathname.startsWith("/subapp") || request.nextUrl.pathname.startsWith("/api/subapp")) {
    return NextResponse.redirect(new URL('/mainapp', request.url))
  }
  return NextResponse.next()
}

export const config = {
  matcher: [
    "/mainapp/:path*",
    "/app/mainapp/:path*",
    "/subapp/:path*",
    "/app/subapp/:path*"
  ]
}

Further Details

With the configuration up to next.config.js, the main part of the separation is complete. From here, I will describe some more advanced aspects.

Separating Layouts

We also switch the layout part. Layouts are switched based on the path from useRouter.

// _app.tsx
const SubappLayout: FC<PropsWithChildren<{}>> = ({ children }) => {
  return <Stack>
    <Box bg="blue.100">Sub app</Box>
    <Box>
      {children}
    </Box>
  </Stack>
}

const MainappLayout: FC<PropsWithChildren<{}>> = ({ children }) => {
  return <Stack>
    <Box bg="red.100">Main app</Box>
    <Box>
      {children}
    </Box>
  </Stack>
}

const AppLayout: FC<PropsWithChildren<{}>> = ({ children }) => {
  const router = useRouter()
  if (router.pathname.startsWith("/subapp")) {
    return <SubappLayout>
      {children}
    </SubappLayout>
  }

  return <MainappLayout>
    {children}
  </MainappLayout>

}

function App({ Component, pageProps }: AppProps) {
  return <AppLayout>
    <Component {...pageProps} />
  </AppLayout>
}

Redirect Settings for index

This is a matter of preference, but if you want to apply a redirect to the top root index.ts, it is a good idea to check the env in getServerSideProps or similar.

import { GetServerSideProps } from 'next'

export const getServerSideProps: GetServerSideProps = async () => {
  if (process.env.APP_MODE === "subapp") {
    return {
      redirect: {
        destination: "/subapp",
        statusCode: 301
      }
    }
  }
  return {
    redirect: {
      destination: "/mainapp",
      statusCode: 301
    }
  }
}

export default function Home() {
  return null
}

2. Adjusting Webpack Settings to Resolve Dependencies

This method can be implemented in a relatively clean way, but it requires a bit of commitment as you might need to modify the Webpack configuration.

In this method, we separate each application by root directory:

.
├── app-mainapp // Main app
│   ├── next-env.d.ts
│   ├── pages
│   │   └── index.tsx
│   └── tsconfig.json
├── app-subapp // Sub app
│   ├── next-env.d.ts
│   ├── pages
│   │   └── index.tsx
│   └── tsconfig.json
├── shared // Shared parts
│   └── SharedComponent.tsx

With this method, each Next.js instance is almost completely independent, so there's no need to implement logic in layouts or similar parts. However, for the shared directory where common parts are gathered, it will fail if run normally, so action is required. This will be described later.

You can specify the source directory to start with using next dev [dir], so we will use that.

    "dev": "next dev app-mainapp",
    "dev:subapp": "next dev app-subapp -p 3002",

When started, .next will appear under each directory, so add the following to .gitignore:

app-*/.next

Similarly, a tsconfig.json will be generated for each directory. This is also tedious, so it's easier to share settings using extends like this:

{
  "extends": "../tsconfig.json",
}

Changing Webpack Settings in next.config.js (When a Shared Directory Exists)

With the default next.config.js, only directories under app-xxx might be compiled or module-resolved. If you have a shared directory, you can resolve this by adding Webpack settings as shown below.

// next.config.js

const appendRootDir = (rule) => {
  if (!Array.isArray(rule?.include)) {
    return rule
  }
  // If include settings exist, add the directory to be compiled
  rule.include = [...rule.include, __dirname]    
  return rule
}

module.exports = {
  webpack: (config) => {
    config.module.rules.map(rule => {
      if (Array.isArray(rule.oneOf)) {
        return {
          oneOf: rule.oneOf.map(rule => appendRootDir(rule))
        }
      }
      // Target only next-swc-loader
      if (rule?.use?.loader !== "next-swc-loader") {
        return rule
      }
      return appendRootDir(rule)
    })
    return config
  }
}

In Next.js 12 or higher, TypeScript is usually resolved by next-swc-loader, so we rewrite the include setting for the next-swc-loader loader.

If making the entire __dirname a target is too much, it is also a good idea to narrow it down to a directory like __dirname/shared.

GitHubで編集を提案

Discussion