Monorepo concepts, tips and tricks oriented around NextJs
WARNING This document covers the most recent version based on Yarn 3.0 and NextJs 10.2+. Docs and examples are still WIP.
Useful to
- Establish a structure and show a lifecycle perspective (dx, ci/cd...)
- How to create shared packages, shared locales, assets, images folders, api types...
- Integrate tools & configs (ts, jest, changelogs, versioning...).
- Clarify some advantages of monorepos (team cohesion, consistency, duplication...).
- Create nextjs/vercel/prisma/webpack5... bug reports with reproducible examples (initial goal of this repo).
The approach doesn't rely on monorepo tools such as Rush or Nx. It does not try to compete, accent is on recipes with a focus on workspace enabled package managers like yarn 3.0, pnpm, npm v7... By keeping the examples as agnostic as possible, it should be very easy to apply them in others tools. Code is shared through typescript aliases (no build necessary), topology and dependency graph handled by the package manager, caches by NextJs. See also the FAQ about differences.
All in typescript, latest nextjs 10.2+, webpack5, yarn v3, ts-jest, prettier, eslint, emotion, tailwind, prisma 2... add as much as you like.
- apps/web-app: SSR and API. README | DEMO/Vercel | CHANGELOG
- apps/blog-app: SSG. README | DEMO/Vercel | CHANGELOG
Apps should not depend on apps, they can depend on packages
- packages/core-lib: used by web-app and blog-app, publishable. README | CHANGELOG
- packages/db-main-prisma: used by web-app. README | CHANGELOG
- packages/ui-lib: used by web-app and blog-app, publishable. README | CHANGELOG
Apps can depend on packages, packages can depend on each others...
If needed static resources like locales, images,... can be shared by using symlinks in the repo.
- See the global static folder.
.
├── apps
│ ├── blog-app (NextJS SSG app)
│ │ ├── public/
│ │ │ └── shared-assets/ (symlink to global static/assets)
│ │ ├── src/
│ │ ├── CHANGELOG.md (autogenerated with changesets)
│ │ ├── jest.config.js
│ │ ├── next.config.js
│ │ ├── package.json (define package workspace:package deps)
│ │ └── tsconfig.json (define path to packages)
│ │
│ └── web-app (NextJS app with api-routes)
│ ├── public/
│ │ ├── shared-assets/ (possible symlink to global assets)
│ │ └── shared-locales/ (possible symlink to global locales)
│ ├── src/
│ │ └── pages/api (api routes)
│ ├── CHANGELOG.md
│ ├── jest.config.js
│ ├── next.config.js
│ ├── package.json (define package workspace:package deps)
│ └── tsconfig.json (define path to packages)
│
├── packages
│ ├── core-lib (basic ts libs)
│ │ ├── src/
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── main-db-prisma (basic db layer with prisma)
│ │ ├── prisma/
│ │ ├── src/
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ └── ui-lib (basic design-system in react)
│ ├── src/
│ ├── CHANGELOG.md
│ ├── package.json
│ └── tsconfig.json
│
├── static (no code: images, json, locales,...)
│ ├── assets
│ └── locales
├── .yarnrc.yml
├── docker-compose.yml (database service for now)
├── package.json (the workspace config)
└── tsconfig.base.json (base typescript config)
-
Workspace config lives in the root package.json, see workspace section. there's already 2 roots defined: ./packages/_ and ./apps/_. So nothing to do.
-
Create a new folder, i.e:
mkdir packages/magnificent-poney
. -
Initialize a
package.json
, set a name and dependencies you'll need. For inspiration, take the ui-lib as an example. Copy/paste other files you might need (tsconfig.json...). Place sources in themagnificent-poney/src
folder. -
To use it in an app first declare the dependency in its package.json deps by adding
"@your-org/magnificent-poney": "workspace:*"
. Inspiration in web-app/package.json. -
Run
yarn install
to update the workspace and create symlinks. -
Add tsconfig paths in the app
tsconfig.json
, take an example in web-app/tsconfig.json{ "compilerOptions": { "baseUrl": "./src", "paths": { // regular app aliases "@/components/*": ["./components/*"], // packages aliases, relative to app_directory/baseUrl "@your-org/magnificent-poney/*": ["../../../packages/magnificent-poney/src/*"], "@your-org/magnificent-poney": ["../../../packages/magnificent-poney/src/index"] }, }
PS: The packages aliases should be declared per app (not in the tsconfig.base.json), so to keep being explicit with the dependencies.
-
Be sure your next.config.js app overrides webpack like in nextjs.config.js:
webpack: function(config, { defaultLoaders }) { // Will allow transpilation of shared packages through tsonfig paths // @link https://github.com/vercel/next.js/pull/13542 const resolvedBaseUrl = path.resolve(config.context, '../../'); config.module.rules = [ ...config.module.rules, { test: /\.(tsx|ts|js|jsx|json)$/, include: [resolvedBaseUrl], use: defaultLoaders.babel, exclude: (excludePath) => { return /node_modules/.test(excludePath); }, }, ]; return config; }
PS:
- NextJS 10.2+ has an experimental.externalDir option for monorepo, when time comes it might allow to skip the webpack config override above.
- If your shared package make use of scss bundler... A custom webpack configuration will be necessary or use next-transpile-modules, see FAQ below.
-
Using the package in your app
The packages are now linked to your app, just import them like regular packages:
import { poney } from '@your-org/magnificent-poney'
. -
Optional package publishing.
If you need to share some packages outside of the monorepo, you can publish them to npm or private repositories. An example based on microbundle is present in each package. Versioning and publishing can be done with atlassian/changeset, and it's simple as typing:
$ yarn changeset
Follow the instructions... and commit the changeset file. A "Version Packages" P/R will appear after CI checks. When merging it, a github action will publish the packages with resulting semver version and generate CHANGELOGS for you.
PS:
- Even if you don't need to publish, changeset can maintain an automated changelog for your apps. Nice !
- To disable automatic publishing of some packages, just set
"private": "true"
in their package.json. - Want to tune the behaviour, see .changeset/config.json.
Some convenience global scripts are defined in the root package.json, they generally call their counterparts defined in packages and apps.
{
"scripts": {
"clean": "yarn workspaces foreach -ptv run clean",
"test": "run-s 'test:*'",
"test:unit": "yarn workspaces foreach -ptv run test:unit",
"fix:staged-files": "yarn workspaces foreach -t run fix:staged-files",
"fix:all-files": "yarn workspaces foreach -ptv run fix:all-files",
// Manage versions and releases with atlassion/changesets
"changeset": "changeset",
"release": "yarn build && changeset publish",
// Utility scripts to check/upgrade deps across the entire monorepo
// use yarn dedupe after install
"deps:check": "npm-check-updates --deep --dep prod,dev,optional",
"deps:update": "npm-check-updates -u --deep --dep prod,dev,optional",
"typecheck": "yarn workspaces foreach -ptv run typecheck",
"lint": "yarn workspaces foreach -ptv run lint",
"share:static:symlink": "yarn workspaces foreach -pv --include '*-app' run share:static:symlink",
"share:static:hardlink": "yarn workspaces foreach -pv --include '*-app' run share:static:hardlink",
"apps:build": "yarn workspaces foreach -ptv --include '*-app' run build",
"apps:clean": "yarn workspaces foreach -ptv --include '*-app' run clean",
"packages:build": "yarn workspaces foreach -ptv --include '@your-org/*' run build",
"packages:lint": "yarn workspaces foreach -ptv --include '@your-org/*' run lint",
"packages:typecheck": "yarn workspaces foreach -ptv --include '@your-org/*' run typecheck",
"packages:clean": "yarn workspaces foreach -ptv --include '@your-org/*' run clean",
"docker:up": "docker-compose up -d",
"docker:up:database": "docker-compose up -d database",
"docker:down": "docker-compose down",
"docker:clean": "docker container rm -f $(docker container ls -qa) && docker image rm -f $(docker image ls -q)",
},
}
PS:
- Convention: whatever the script name (ie: test:unit), keeps it consistent over root commands, packages and apps.
- The use of yarn workspaces commands can be replicated in pnpm, nmp7+lerna...
The global commands yarn deps:check
and yarn deps:update
will help to maintain the same versions across the entire monorepo.
They are based on the excellent npm-check-updates
(see options, i.e: yarn check:deps -t minor
).
After running
yarn deps:update
, ayarn install
is required. To prevent having duplicates in the yarn.lock, you can runyarn dedupe --check
andyarn dedupe
to apply deduplication. The duplicate check is enforced in the example github actions.
An example of base eslint configuration can be found in ./.eslint.base.json, apps and packages extends it in their own root folder, as an example see ./apps/web-app/.eslintrc.json. Prettier is included in eslint configuration as well as eslint-config-next for nextjs apps.
Check the .husky folder content to see what hooks are enabled. Lint-staged is used to guarantee that lint and prettier are applied automatically on commit and/or pushes.
Tests relies on ts-jest with support for typescript path aliases. React-testing-library is enabled whenever react is involved. Configuration lives in the root folder of each apps/packages. As an example see ./apps/web-app/jest.config.js.
You'll find some example workflows for github action in .github/workflows. By default, they will ensure that
- You don't have package duplicates.
- You don't have typecheck errors.
- You don't have linter / code-style errors.
- Your test suite is successful.
- Your apps (nextjs) or packages can be successfully built.
Each of those steps can be opted-out.
To ensure decent performance, those features are present in the example actions:
-
Caching of packages (node_modules...) - install around 25s
-
Caching of nextjs previous build - built around 20s
-
Triggered when changed using actions paths, ie:
paths: - "apps/blog-app/**" - "packages/**" - "package.json" - "tsconfig.base.json" - "yarn.lock" - ".yarnrc.yml" - ".github/workflows/**" - ".eslintrc.base.json" - ".eslintignore"
Vercel support natively monorepos, see the vercel-monorepo-deploy document.
Netlify, aws-amplify, k8s-docker, serverless-nextjs recipes might be added in the future. PR's welcome too.
Apps dependencies and devDependencies are pinned to exact versions. Packages deps will use semver compatible ones. For more info about this change see reasoning here and our renovabot.json5 configuration file.
To help keeping deps up-to-date, see the yarn deps:check && yarn deps:update
scripts and / or use the renovatebot.
When adding a dep through yarn cli (i.e.: yarn add something), it's possible to set the save-exact behaviour automatically by setting
defaultSemverRangePrefix: ""
in yarnrc.yml. But this would make the default for packages/* as well. Better to handleyarn add something --exact
on per-case basis.
And why this repo example doesn't use it to support package sharing.
next-transpile-modules is one of the most installed packages for nextjs. It basically allows you to transpile some 3rd party packages present in your node_modules folder. This can be helpful for transpiling packages for legacy browser support (ie11), esm packages (till it lands in nextjs) and handle shared packages.
In this repo, we use next-transpile-modules only for ie11 and esm. The monorepo management is done through tsconfig path. It will work best when external tooling is involved (ts-jest...), but comes with some limitations if your shared package use an scss compiler for example. Note that future version of NextJs might improve monorepo support through experimental.externalDir option.
See here a quick comparison:
Support matrix | tsconfig paths | next-transpile-module |
---|---|---|
Typescript | ✅ | ✅ |
Javascript | ✅ | ✅ |
NextJs Fast refresh | ✅ | ✅ |
CSS | custom webpack cfg | ✅ |
SCSS | custom webpack cfg | ✅ |
CSS-in-JS | ✅ | ✅ |
ts-jest | ✅ | custom aliases |
Vercel monorepo | ✅ | ✅ |
Yarn 2 PNP | ✅ | ✅ |
Webpack5 | ✅ | ✅ |
Publishable (npm) | ✅ | ❌ (ntm relies on "main") |