Creating a Monorepo with NextJS and Yarn Workspaces: A How-to Guide

August 12, 2023

7 min read

Creating a Monorepo with NextJS and Yarn Workspaces: A How-to Guide
Watch on YouTube

These days, I use NextJS for my front-end work, and I prefer to do it within a monorepo. This approach allows me to store all the front-ends of the product in the same repository along with back-end services and shared libraries. This setup enables me to reuse code and finish work faster. I will guide you through how to establish such a monorepo using Yarn Workspaces and NextJS. The source code and commands can be found here. Further, you could also use RadzionKit as a template for your project instead of going through the post.

Create a Monorepo with Yarn Workspaces

Firstly, verify that you have the latest stable version of Yarn installed. In case you do not have Yarn installed, follow the instructions from their website.

yarn set version stable

Then, create a folder for your project and add a package.json with the subsequent content.

{
  "name": "radzionkit",
  "packageManager": "yarn@3.6.1",
  "engines": {
    "yarn": "3.x"
  },
  "private": true,
  "workspaces": ["./*"],
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "latest",
    "@typescript-eslint/parser": "latest",
    "eslint": "latest",
    "husky": "latest",
    "lint-staged": "latest",
    "prettier": "latest",
    "typescript": "latest"
  },
  "scripts": {
    "postinstall": "husky install",
    "format": "eslint --fix && prettier --write"
  }
}

For this tutorial, we will use the name radzionkit to maintain consistency with my GitHub Template. The packageManager and engines fields are optional; they can be employed to compel your team to utilize the same package manager. Our monorepo will not be published to NPM, so set private to true. We will incorporate a flat structure, thus you can consider everything in the root directory a potential Yarn workspace.

The devDependencies incorporate typescript and libraries for linting and formatting. The "scripts" section includes a postinstall script that installs Husky hooks, and a format script that formats and lints the entire project.

Next, add a tsconfig.json for TypeScript configuration.

{
  "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": "react-jsx"
  },
  "exclude": ["node_modules"],
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "types.d.ts"]
}

To customize Prettier, include a .prettierrc file:

{
  "singleQuote": true,
  "semi": false,
  "tabWidth": 2
}

It is optional to add a .prettierignore file, which will utilize the same syntax as .gitignore to exclude certain files from formatting.

.vscode
.yarn
.next
out

To execute Prettier and eslint upon commit, you can add a .lintstagedrc.json file with instructions for the lint-staged library:

{
  "*.{js,ts,tsx,json}": ["yarn format"]
}

Below is a generic Eslint config for our monorepo:

{
  "env": {
    "browser": true,
    "es2021": true
  },
  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": "latest",
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint"],
  "rules": {}
}

Let's include a .gitignore file to eliminate unnecessary yarn files and node_modules:

.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.DS_Store

node_modules

Subsequently, generate a new git repository with git init:

git init

To format files during the pre-commit phase, we will use the husky library. Initialize it with this command:

npx husky-init

For the pre-commit hook, we want to run lin-staged. So, modify the .husky/pre-commit file:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

yarn lint-staged

Before creating our first workspace, you must decide whether to use Yarn's plug'n'play feature or not. Plug'n'play is the default method of managing dependencies in the latest Yarn, but it might not work for certain packages that rely on the traditional method of having all packages in the same node_modules directory and I found it to be quite troublesome. I believe the ecosystem is not quite ready for it yet, so it might be better to try it in a year. For now, we'll proceed with node_modules by adding the following line to the .yarnrc.yml file:

nodeLinker: node-modules

Now, you can install dependencies with:

yarn

Add NextJS App to the Monorepo

Our NextJS app requires a UI package with a components system. So, let's copy the ui folder from RadzionKit to our monorepo.

To establish a new NextJS project, we will use the create-next-app command:

npx create-next-app@latest app

We won't use server components in this tutorial, so there is no need for the app router.

Setup NextJS App
Setup NextJS App

Update the next.config.js file to add support for styled-components, static export, and to transpile our shared UI package:

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  compiler: {
    styledComponents: true,
  },
  output: "export",
  transpilePackages: ["@radzionkit/ui"],
}

// eslint-disable-next-line no-undef
module.exports = nextConfig

Also, you should install styled-component and next-sitemap packages.

yarn add styled-components@^5.3.5 next-sitemap
yarn add --dev @types/styled-components@^5.1.25

To generate a sitemap, add a next-sitemap.config.js file. I prefer to provide siteUrl from the environment variable - NEXT_PUBLIC_BASE_URL

/** @type {import('next-sitemap').IConfig} */
module.exports = {
  siteUrl: process.env.NEXT_PUBLIC_BASE_URL,
  generateRobotsTxt: true,
  generateIndexSitemap: false,
  outDir: "./out",
}

Also, update the build command for the app:

{
  "scripts": {
    "build": "next build && next-sitemap"
  }
}

To use absolute imports within the app project, update the tsconfig.json file with the compilerOptions field:

{
  "compilerOptions": {
    "baseUrl": "."
  }
}

For efficient use of local storage, we'll copy the state folder from RadzionKit. To understand more about the implementation, you can check this post.

We'll do the same thing with the ui folder that gives support for the dark and light mode along with the theme for styled-components. You can watch a video on this topic here.

We also need to update the _document.tsx file to support styled-components and add basic meta tags. To learn more about generating icons for PWA, you can check this post.

import Document, {
  Html,
  Main,
  NextScript,
  DocumentContext,
  Head,
} from "next/document"
import { ServerStyleSheet } from "styled-components"
import { MetaTags } from "@radzionkit/ui/metadata/MetaTags"
import { AppIconMetaTags } from "@radzionkit/ui/metadata/AppIconMetaTags"

class MyDocument extends Document {
  static async getInitialProps(ctx: DocumentContext) {
    const sheet = new ServerStyleSheet()
    const originalRenderPage = ctx.renderPage
    try {
      ctx.renderPage = () =>
        originalRenderPage({
          enhanceApp: (App) => (props) =>
            sheet.collectStyles(<App {...props} />), //gets the styles from all the components inside <App>
        })
      const initialProps = await Document.getInitialProps(ctx)
      return {
        ...initialProps,
        styles: (
          <>
            {initialProps.styles}
            {sheet.getStyleElement()}
          </>
        ),
      }
    } finally {
      sheet.seal()
    }
  }
  render() {
    return (
      <Html lang="en">
        <Head>
          <MetaTags
            title="RadzionKit"
            description="A React components system for faster development"
            url={process.env.NEXT_PUBLIC_BASE_URL}
            twitterId="@radzionc"
          />
          <AppIconMetaTags />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

We will be using the styled-component, so let's remove the styles folder and modify the _app.tsx file:

import type { AppProps } from "next/app"
import { GlobalStyle } from "@radzionkit/ui/ui/GlobalStyle"
import { ThemeProvider } from "ui/ThemeProvider"
import { Inter } from "next/font/google"

const inter = Inter({
  subsets: ["latin"],
  weight: ["400", "500", "600", "800"],
})

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider>
      <GlobalStyle fontFamily={inter.style.fontFamily} />
      <Component {...pageProps} />
    </ThemeProvider>
  )
}

export default MyApp

Finally, we can try to import one of the reusable components from the ui package to the index.tsx page:

import { Center } from "@radzionkit/ui/ui/Center"
import { Button } from "@radzionkit/ui/ui/buttons/Button"

export default function Home() {
  return (
    <Center>
      <Button size="xl">RadzionKit is Awesome!</Button>
    </Center>
  )
}

To deploy a static NextJS app to AWS S3 and CloudFront, you can follow this post.