iTranslated by AI
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.
- Separate apps by directory and switch them via environment variables (ENV).
- 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.
package.json and related files
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.
Discussion