Next.js Monorepo : A Way to become master

A Comprehensive Guide with 10 Real-World Scenarios

·

9 min read

Introduction to Monorepos :

A monorepo is a version-controlled code repository that holds many projects. For Next.js applications, this approach can be particularly beneficial when managing multiple interconnected projects, shared libraries, and microservices.Benefits of monorepos:

  • Simplified dependency management

  • Easier code sharing and reuse

  • Atomic commits across projects

  • Simplified CI/CD pipelines

  1. Setting Up a Basic Next.js Monorepo

Let's start by setting up a basic Next.js monorepo using Yarn workspaces.Folder structure:

nextjs-monorepo/
├── package.json
├── yarn.lock
├── packages/
│   ├── app1/
│   │   ├── package.json
│   │   └── ...
│   ├── app2/
│   │   ├── package.json
│   │   └── ...
│   └── shared/
│       ├── package.json
│       └── ...
└── .gitignore

Root package.json:

{
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "dev": "yarn workspaces run dev",
    "build": "yarn workspaces run build"
  }
}
  1. Scenario 1: Multiple Next.js Applications

In this scenario, we'll set up two Next.js applications within our monorepo.Folder structure:

nextjs-monorepo/
├── packages/
│   ├── app1/
│   │   ├── pages/
│   │   │   └── index.js
│   │   ├── package.json
│   │   └── next.config.js
│   └── app2/
│       ├── pages/
│       │   └── index.js
│       ├── package.json
│       └── next.config.js
└── package.json

app1/package.json:

{
  "name": "@myorg/app1",
  "version": "1.0.0",
  "scripts": {
    "dev": "next dev -p 3000",
    "build": "next build"
  },
  "dependencies": {
    "next": "^11.0.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  }
}

app2/package.json:

{
  "name": "@myorg/app2",
  "version": "1.0.0",
  "scripts": {
    "dev": "next dev -p 3001",
    "build": "next build"
  },
  "dependencies": {
    "next": "^11.0.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  }
}
  1. Scenario 2: Shared Components Library

Create a shared component library that can be used across multiple Next.js applications.Folder structure:

nextjs-monorepo/
├── packages/
│   ├── shared/
│   │   ├── components/
│   │   │   ├── Button.js
│   │   │   └── Header.js
│   │   ├── package.json
│   │   └── index.js
│   ├── app1/
│   │   ├── pages/
│   │   │   └── index.js
│   │   └── package.json
│   └── app2/
│       ├── pages/
│       │   └── index.js
│       └── package.json
└── package.json

shared/package.json:

{
  "name": "@myorg/shared",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "react": "^17.0.2"
  }
}

shared/index.js:

export { default as Button } from './components/Button';
export { default as Header } from './components/Header';

app1/package.json:

{
  "name": "@myorg/app1",
  "version": "1.0.0",
  "dependencies": {
    "@myorg/shared": "1.0.0",
    "next": "^11.0.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  }
}

app1/pages/index.js:

import { Button, Header } from '@myorg/shared';

export default function Home() {
  return (
    <div>
      <Header />
      <h1>Welcome to App 1</h1>
      <Button>Click me</Button>
    </div>
  );
}
  1. Scenario 3: API Services with Express

Add an Express.js API service to your monorepo.Folder structure:

nextjs-monorepo/
├── packages/
│   ├── api/
│   │   ├── src/
│   │   │   ├── routes/
│   │   │   │   └── users.js
│   │   │   └── index.js
│   │   ├── package.json
│   │   └── nodemon.json
│   ├── app1/
│   │   └── ...
│   └── app2/
│       └── ...
└── package.json

api/package.json:

{
  "name": "@myorg/api",
  "version": "1.0.0",
  "scripts": {
    "dev": "nodemon src/index.js",
    "start": "node src/index.js"
  },
  "dependencies": {
    "express": "^4.17.1"
  },
  "devDependencies": {
    "nodemon": "^2.0.7"
  }
}

api/src/index.js:

const express = require('express');
const usersRouter = require('./routes/users');

const app = express();
const port = process.env.PORT || 4000;

app.use('/api/users', usersRouter);

app.listen(port, () => {
  console.log(`API server running on port ${port}`);
});
  1. Scenario 4: TypeScript Integration

Convert the monorepo to use TypeScript.Folder structure:

nextjs-monorepo/
├── packages/
│   ├── shared/
│   │   ├── components/
│   │   │   ├── Button.tsx
│   │   │   └── Header.tsx
│   │   ├── package.json
│   │   └── index.ts
│   ├── app1/
│   │   ├── pages/
│   │   │   └── index.tsx
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── app2/
│       ├── pages/
│       │   └── index.tsx
│       ├── package.json
│       └── tsconfig.json
├── package.json
└── tsconfig.base.json

tsconfig.base.json:

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve"
  },
  "exclude": ["node_modules"]
}

app1/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}
  1. Scenario 5: Third-Party Authentication (Auth0)

Implement Auth0 authentication in your Next.js applications.Folder structure:

nextjs-monorepo/
├── packages/
│   ├── auth/
│   │   ├── src/
│   │   │   ├── withAuth.ts
│   │   │   └── AuthProvider.tsx
│   │   └── package.json
│   ├── app1/
│   │   ├── pages/
│   │   │   ├── index.tsx
│   │   │   └── api/
│   │   │       └── auth/
│   │   │           └── [...auth0].ts
│   │   ├── package.json
│   │   └── .env.local
│   └── app2/
│       └── ...
└── package.json

auth/package.json:

{
  "name": "@myorg/auth",
  "version": "1.0.0",
  "main": "src/index.ts",
  "dependencies": {
    "@auth0/nextjs-auth0": "^1.5.0"
  }
}

auth/src/AuthProvider.tsx:

import { UserProvider } from '@auth0/nextjs-auth0';
import React from 'react';

export const AuthProvider: React.FC = ({ children }) => {
  return <UserProvider>{children}</UserProvider>;
};

app1/.env.local:

AUTH0_SECRET='your-auth0-secret'
AUTH0_BASE_URL='http://localhost:3000'
AUTH0_ISSUER_BASE_URL='https://your-domain.auth0.com'
AUTH0_CLIENT_ID='your-auth0-client-id'
AUTH0_CLIENT_SECRET='your-auth0-client-secret'

app1/pages/api/auth/[...auth0].ts:

import { handleAuth } from '@auth0/nextjs-auth0';

export default handleAuth();
  1. Scenario 6: Microfrontends with Module Federation

Implement microfrontends using Webpack 5's Module Federation.Folder structure:

nextjs-monorepo/
├── packages/
│   ├── host/
│   │   ├── next.config.js
│   │   ├── pages/
│   │   │   └── index.js
│   │   └── package.json
│   ├── remote1/
│   │   ├── next.config.js
│   │   ├── pages/
│   │   │   └── index.js
│   │   └── package.json
│   └── remote2/
│       ├── next.config.js
│       ├── pages/
│       │   └── index.js
│       └── package.json
└── package.json

host/next.config.js:

const { NextFederationPlugin } = require('@module-federation/nextjs-mf');

module.exports = {
  webpack(config, options) {
    config.plugins.push(
      new NextFederationPlugin({
        name: 'host',
        remotes: {
          remote1: 'remote1@http://localhost:3001/_next/static/chunks/remoteEntry.js',
          remote2: 'remote2@http://localhost:3002/_next/static/chunks/remoteEntry.js',
        },
        shared: {
          react: {
            singleton: true,
            requiredVersion: false,
          },
        },
      })
    );
    return config;
  },
};
  1. Scenario 7: E2E Testing with Cypress

Add end-to-end testing with Cypress to your monorepo.Folder structure:

nextjs-monorepo/
├── packages/
│   ├── app1/
│   │   └── ...
│   ├── app2/
│   │   └── ...
│   └── e2e/
│       ├── cypress/
│       │   ├── integration/
│       │   │   ├── app1.spec.js
│       │   │   └── app2.spec.js
│       │   └── support/
│       │       └── commands.js
│       ├── cypress.json
│       └── package.json
└── package.json

e2e/package.json:

{
  "name": "@myorg/e2e",
  "version": "1.0.0",
  "scripts": {
    "test": "cypress run",
    "test:open": "cypress open"
  },
  "devDependencies": {
    "cypress": "^7.3.0"
  }
}

e2e/cypress/integration/app1.spec.js:

describe('App1', () => {
  it('should load the home page', () => {
    cy.visit('http://localhost:3000');
    cy.contains('Welcome to App 1');
  });
});
  1. Scenario 8: Internationalization (i18n)

Implement internationalization across your Next.js applications.Folder structure:

nextjs-monorepo/
├── packages/
│   ├── i18n/
│   │   ├── locales/
│   │   │   ├── en.json
│   │   │   └── es.json
│   │   ├── index.ts
│   │   └── package.json
│   ├── app1/
│   │   ├── pages/
│   │   │   └── index.tsx
│   │   ├── next.config.js
│   │   └── package.json
│   └── app2/
│       └── ...
└── package.json

i18n/package.json:

{
  "name": "@myorg/i18n",
  "version": "1.0.0",
  "main": "index.ts",
  "dependencies": {
    "next-i18next": "^8.1.0"
  }
}

i18n/index.ts:

import { useTranslation, withTranslation, Trans } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';

export { useTranslation, withTranslation, Trans, serverSideTranslations };

app1/next.config.js:

const { i18n } = require('./next-i18next.config');

module.exports = {
  i18n,
};

app1/pages/index.tsx:

import { useTranslation } from '@myorg/i18n';
export default function Home() { const { t } = useTranslation('common');
return ( <div> <h1>{t('welcome')}</h1> </div> ); }
export const getStaticProps = async ({ locale }) =>
 ({ props: { ...(await serverSideTranslations(locale, ['common'])), }, });
  1. Scenario 9: State Management with Redux Toolkit

Implement global state management using Redux Toolkit across your applications. Folder structure:

nextjs-monorepo/
├── packages/
│   ├── state/
│   │   ├── src/
│   │   │   ├── slices/
│   │   │   │   └── userSlice.ts
│   │   │   ├── store.ts
│   │   │   └── hooks.ts
│   │   └── package.json
│   ├── app1/
│   │   ├── pages/
│   │   │   └── index.tsx
│   │   └── package.json
│   └── app2/
│       └── ...
└── package.json

state/package.json:




{
  "name": "@myorg/state",
  "version": "1.0.0",
  "main": "src/index.ts",
  "dependencies": {
    "@reduxjs/toolkit": "^1.5.1",
    "react-redux": "^7.2.4"
  }
}

state/src/slices/userSlice.ts:

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface UserState {
  name: string;
  email: string;
}

const initialState: UserState = {
  name: '',
  email: '',
};

export const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    setUser: (state, action: PayloadAction<UserState>) => {
      state.name = action.payload.name;
      state.email = action.payload.email;
    },
  },
});

export const { setUser } = userSlice.actions;
export default userSlice.reducer;

state/src/store.ts:

import { configureStore } from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';

export const store = configureStore({
  reducer: {
    user: userReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
  1. Scenario 10: GraphQL API with Apollo Server

Add a GraphQL API using Apollo Server to your monorepo.Folder structure:

nextjs-monorepo/
├── packages/
│   ├── graphql-api/
│   │   ├── src/
│   │   │   ├── schema.ts
│   │   │   ├── resolvers.ts
│   │   │   └── index.ts
│   │   └── package.json
│   ├── app1/
│   │   ├── pages/
│   │   │   └── index.tsx
│   │   └── package.json
│   └── app2/
│       └── ...
└── package.json

graphql-api/package.json:

{
  "name": "@myorg/graphql-api",
  "version": "1.0.0",
  "main": "src/index.ts",
  "scripts": {
    "start": "ts-node src/index.ts"
  },
  "dependencies": {
    "apollo-server": "^3.0.0",
    "graphql": "^15.5.0"
  },
  "devDependencies": {
    "ts-node": "^10.0.0",
    "typescript": "^4.3.2"
  }
}

graphql-api/src/schema.ts:

import { gql } from 'apollo-server';

export const typeDefs = gql
  type User {
    id: ID!
    name: String!
    email: String!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
  }

  type Mutation {
    createUser(name: String!, email: String!): User!
  }
;

graphql-api/src/resolvers.ts:

const users = [
  { id: '1', name: 'John Doe', email: 'john@example.com' },
  { id: '2', name: 'Jane Smith', email: 'jane@example.com' },
];

export const resolvers = {
  Query: {
    users: () => users,
    user: (_, { id }) => users.find(user => user.id === id),
  },
  Mutation: {
    createUser: (_, { name, email }) => {
      const newUser = { id: String(users.length + 1), name, email };
      users.push(newUser);
      return newUser;
    },
  },
};

graphql-api/src/index.ts:

import { ApolloServer } from 'apollo-server';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';

const server = new ApolloServer({ typeDefs, resolvers });

server.listen().then(({ url }) => {
  console.log(`🚀 GraphQL API ready at ${url}`);
});

13. Advanced Monorepo Management

To manage your monorepo more effectively, consider using tools like: - Lerna: For managing multiple packages in a monorepo - Nx: For adding intelligent build system and CLI to your monorepo - Changesets: For managing versioning and changelogs

14. Conclusion

In this comprehensive guide, we've explored ten different scenarios for building and managing a Next.js monorepo. From setting up multiple applications to implementing shared libraries, API services, and various frontend technologies, you now have a solid foundation for creating scalable and maintainable monorepo projects. Remember that the key to a successful monorepo is finding the right balance between shared code and application-specific implementations. As your project grows, continue to refactor and optimize your monorepo structure to ensure it remains manageable and efficient.

Did you find this article valuable?

Support Mikey's Blog by becoming a sponsor. Any amount is appreciated!