Resume
โ† Back to blogโ˜•โ˜•โ˜• ยท 14 min read

Creating a Next.js mono repository project with TypeScript

In this post, we'll be exploring how to set up a Next.js mono repository project taking advantage of both yarn workspaces and TypeScript.

We'll set up a simple blog project from scratch, consisting of an application workspace, an API workspace and a shared component library workspace. The goal is to be able to make source file changes in one workspace that hot-reloads in other running workspaces without having to run any manual build tasks, pull from npm, github or otherwise.

This post assumes you're already familiar with yarn, npm, TypeScript, Next.js and React. It's lengthy, so grab a coffee and a fresh terminal.

TLDR?

If you prefer to jump straight in to the code, you can visit the example repository that accompanies this post.


Some explanations

Why a mono repository?

A mono repository project structure is a popular choice for projects that benefit from sharing code such as open-source projects that publish many libraries from the same codebase, or products that have many deployable applications that wish to share some code between them.

TypeScript

Although certainly not a requirement for mono repository projects, TypeScript is a great tool and there are some specific configurations needed to get TypeScript to work well with a mono repository and Next.js.


Setting things up

We'll be using Yarn as our dependency management tool and task runner. If you don't already have it, you can install it using npm:

npm i -g yarn

Go ahead and create a new directory for the mono repository:

mkdir acme
cd acme

Acme is the name of the project for the purposes of this post. Feel free to name this whatever you like!

Initialize the mono repository with yarn:

yarn init

Accept the default options and open up the project in your favorite text editor.

Enabling yarn workspaces

Yarn requires the "private" key to be set to true in package.json, you can read more about why here. Let's go ahead and make that change:

{
  ...
  "private": true
  ...
}

Creating our first workspace

We'll begin by setting up our Next.js application:

mkdir blog
cd blog
yarn init

When yarn prompts, accept the default options again and open up the blog/package.json file that has been created, we'll need to change it's "name" property to enable it to be used in our mono repository:

{
  ...
  "name": "@acme/blog"
  ...
}

We've changed the "name" property to "@acme/blog" where @acme refers to the package's scope. We'll use @acme as the scope for all of workspaces in this project.

Feel free to rename "@acme" to whatever you like, but ensure that you use the same scope in the rest of the code from this post.

Telling yarn that we've got a workspace

Yarn will read the "workspaces" key in the top-level package.json file in your project to lookup workspaces. Let's go ahead and add our new workspace:

{
  ...
  "workspaces": ["blog"]
  ...
}

โš ๏ธ We tell yarn where to find our workspace in the directory structure rather than what the workspace "name" is.

We'll pick up workspaces again in a bit.


Building our blog application

Now workspaces are set up, we're ready to start building our our blog application. Make sure your current working directory is blog and add the following dependencies:

yarn add next
yarn add react
yarn add react-dom
yarn add @zeit/next-typescript
yarn add typescript
yarn add @types/next
yarn add @types/react
yarn add @types/react-dom

โš ๏ธ Once these have finished, yarn will create a yarn.lock file at the root of the repository. If this hasn't happened, or the yarn.lock file is somewhere else in the directory structure, something has gone wrong!

Setting up development scripts

Let's add a couple of scripts to blog/package.json to allow us to develop the application:

{
  ...
  "scripts": {
    "dev": "next",
    "build": "next build"
  }
  ...
}

If you run yarn dev now you'll see the error "Couldn't find a pages directory. Please create one under the project root".

Creating an index page

Next's router will serve the default exported React component in pages/index when visiting the application's ``route. Create and write the following toblog/pages/index.tsx:

import * as React from "react";
import { NextStatelessComponent } from "next";
import Link from "next/link";

interface Props {
  posts: any[];
}

const BlogIndex: NextStatelessComponent<Props> = ({ posts }) => {
  return (
    <div>
      <h1>Acme's blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link passHref href={`${post.id}`}>
              <a>{post.title}</a>
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
};

BlogIndex.getInitialProps = async () => {
  const posts = [
    { id: 1, title: "10 great drinking games" },
    { id: 2, title: "3 amazing hangover antidotes!" },
  ];
  return { posts };
};

export default BlogIndex;

For now, we've used hard-coded data, an any type and simple HTML elements for the design. However, we'll replace these when we build our API and shared component library later.

Go ahead and run the app:

yarn dev

Visit http://localhost:3000.

๐Ÿ˜• We've got a 404 but we were expecting our post listing... What's up?


TypeScript, babel, next, oh my!

So Next.js has an issue picking up the index.tsx file we just made. Clearly there's some work to be done.

Configuring TypeScript

We've already installed TypeScript in our blog workspace, but we haven't created a configuration file yet. We'll be writing our other workspaces in TypeScript as well and it would be nice to share a base configuration so let's create a root level tsconfig.json with the following:

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "node",
    "noEmit": true,
    "skipLibCheck": true,
    "sourceMap": true,
    "target": "esnext"
  }
}

This TypeScript configuration will be shared between all of our workspaces.

Now we can extend it in blog/tsconfig.json workspace:

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "jsx": "preserve",
    "lib": ["dom", "es2017"]
  }
}

This TypeScript configuration will extend the base configuration with specific configuration for our blog project. Other workspaces will have their own TypeScript configurations.

Transpiling via babel

In order to transpile the TypeScript, we need to create a babel.config.js file.

Make a babel.config.js file at the root of the repository so that it is shared between our blog workspace and the other workspaces we'll be making later in the post:

module.exports = function (api) {
  api.cache(true);

  const presets = [
    "next/babel",
    ["@babel/preset-typescript", { isTSX: true, allExtensions: true }],
  ];

  const plugins = [];

  return {
    presets,
    plugins,
  };
};

If you're wondering why we're not using a .babelrc file - the babel.config.js is intended to be shared across projects, you can read more about the babel.config.js file here.

Letting Next.js know what we've done

Lastly, let's tie everything together via blog/next.config.js:

const path = require("path");
const withTypeScript = require("@zeit/next-typescript");
const withCustomBabelConfigFile = require("next-plugin-custom-babel-config");

module.exports = withCustomBabelConfigFile(
  withTypeScript({
    babelConfigFile: path.resolve("../babel.config.js"),
  })
);

We've introduced a new dependency next-plugin-custom-babel-config, and we need to install it. Ensure you're in the blog directory and run:

yarn add next-plugin-custom-babel-config

This plugin is required to monkey patch next-babel-loader to use our shared babel.config.js file.

And...

First milestone done and dusted! ๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰

yarn dev

Visit http://localhost:3000.


On to the API workspace

We've successfully set up a simple Next.js application workspace written in TypeScript that uses a shared babel.config.js and inherits a shared tsconfig.json file, but we've hard-coded the list of posts. Let's fix that by creating our second workspace - a simple API to serve us a list of blog posts.

Setting up the workspace

Ensure your current working directory is the root of the project and run the following:

mkdir api
cd api
yarn init

Accept the defaults and modify the generated api/package.json to rename the package again:

{
  ...
  "name": "@acme/api"
  ...
}

Lastly, update the root package.json to reflect the addition of our new workspace:

{
  ...
  "workspaces": [
    "blog",
    "api"
  ]
  ...
}

Installing dependencies we'll need

It's outside the scope of this post to create a fully-fledged back-end for a blog website, however we'll simulate one using a combination of faker and express. Ensure your current working directory is api and run the following:

yarn add faker
yarn add express
yarn add typescript
yarn add ts-node
yarn add @types/faker
yarn add @types/express

Adding TypeScript

Similar to the blog workspace, add TypeScript configuration to our API workspace via api/tsconfig.json:

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "module": "commonjs"
  }
}

Note that we set "module" to "commonjs" for ts-node

Creating the API

We'll only need a single api/index.ts file for our simple fake API:

import * as express from "express";
import * as faker from "faker";

export namespace Models {
  export interface Post {
    id: string;
    title: string;
    content: string;
  }
}

function randomPost(): Models.Post {
  return {
    id: faker.random.uuid(),
    title: faker.lorem.sentence(),
    content: faker.lorem.paragraphs(),
  };
}

const app = express();

app.get("/posts", (_req, res) => {
  const posts: Models.Post[] = Array.from({ length: 10 }).map(randomPost);
  res.json(posts);
});

app.listen(5000);

console.log("API started on port 5000");

The Models namespace of types is exported so that we can import and use it in other workspaces that consume the API. We get shared types between our front-end and back-end, nice!

Setting up scripts

Lastly we'll create a script in api/package.json to start the API:

{
  ...
  "scripts": {
    "start": "ts-node ./index.ts"
  },
  ...
}

Go ahead and run the app:

yarn start

Visit http://localhost:5000/posts to see a listing of random blog posts.

API client library

In order to keep the interface between the blog application and the API consistent, we'll create a simple API client library implemented in TypeScript that the blog application can import and use to make requests to the API.

Firstly, add axios to the api workspace:

yarn add axios

Create api/client.ts and write the following to it:

import axios from "axios";
import { Models } from ".";

const baseURL = "http://localhost:5000"; // this'd come from some sort of environment configuration in reality

const instance = axios.create({
  baseURL,
});

export const apiClient = {
  posts: {
    async getListing(): Promise<Models.Post[]> {
      const { data } = await instance.get("/posts");
      return data;
    },
  },
};

Back to the blog app

Now that the API is finished, let's update the blog to use the API client we just made.

Since this is the first time we've used a workspace within another workspace, we need to make a small adjustment to the blog/package.json file to tell yarn that we wish to use the API workspace:

{
  ...
  "dependencies": {
    ...
    "@acme/api": "*"
    ...
  }
  ...
}

The * version means that yarn will pick up the workspace.

Now that's out of the way, open up blog/index.tsx and make the following adjustments:

import * as React from "react";
import { NextStatelessComponent } from "next";
import Link from "next/link";
import { Models } from "@acme/api";
import { apiClient } from "@acme/api/client";

interface Props {
  posts: Models.Post[];
}

const BlogIndex: NextStatelessComponent<Props> = ({ posts }) => {
  return (
    <div>
      <h1>Acme's blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link passHref href={`${post.id}`}>
              <a>{post.title}</a>
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
};

BlogIndex.getInitialProps = async (): Promise<Props> => {
  const posts = await apiClient.posts.getListing();
  return { posts };
};

export default BlogIndex;

Keep the API running in one terminal, and open up another in the blog directory and run the app:

yarn dev

Visit http://localhost:3000 to inspect our API-driven blog.

๐Ÿ˜• We've got another error... What's up now!?


Workspace transpilation

Up till now, we've set up Babel to transpile the TypeScript in our workspaces via a root level babel.config.js, but Babel ignores node_modules by default. The blog workspace needs to transpile the the @acme/api workspace (which resides as a symlink in the root level node_modules/@acme/api).

Luckily for us, there's another Next.js plugin we can use called next-plugin-transpile-modules that we can take advantage of to add transpilation of specific node_modules. Let's install it from the blog directory:

yarn add next-plugin-transpile-modules

And update blog/next.config.js to use the plugin:

const path = require("path");
const withTypescript = require("@zeit/next-typescript");
const withCustomBabelConfigFile = require("next-plugin-custom-babel-config");
const withTranspileModules = require("next-plugin-transpile-modules");

module.exports = withCustomBabelConfigFile(
  withTranspileModules(
    withTypescript({
      babelConfigFile: path.resolve("../babel.config.js"),
      transpileModules: ["@acme"],
    })
  )
);

The transpileModules key is set to ["@acme"] which instructs Babel to transpile any modules with the scope @acme. This is great because it means that if we add any other workspaces, we only need to add them to package.json to be able to use them right away. If you prefer to be verbose, you can list out the full package names in an array instead.

And...

Another milestone covered! ๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰

Go ahead and restart the blog application and visit http://localhost:3000.


On to the design workspace

By now, we've covered everything you need to set up a mono repository with Next.js, TypeScript and yarn workspaces, however I promised a shared component library and I'm true to my word ๐Ÿ˜‰

We've been here before

Create the design workspace:

mkdir design
cd design
yarn init

Update design/package.json to scope the package:

{
  ...
  "name": "@acme/design"
  ...
}

Update the root package.json's workspaces:

{
  ...
  "workspaces": [
    "blog",
    "api",
    "design"
  ]
  ...
}

Add dependencies:

yarn add react
yarn add react-dom
yarn add typescript
yarn add styled-components
yarn add @types/react
yarn add @types/react-dom
yarn add @types/styled-components

Configure TypeScript via design/tsconfig.json:

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "jsx": "react"
  }
}

And we're good to go.


Creating the component library

For simplicity, we'll create a single design/components.tsx file that exports some React components built with styled components:

import * as React from "react";
import styled from "styled-components";

const Heading = styled.h1`
  font-size: 2rem;
  line-height: 2.4rem;
  margin: 0 0 1rem;
`;

const Main = styled.main`
  padding: 1rem;
`;

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <Main>
      <Heading>Acme blog</Heading>
      {children}
    </Main>
  );
}

Update blog

Now we're ready to start using the components we've made inside the blog workspace. Firstly, update blog/package.json to include the new workspace as a dependency:

{
  ...
  "dependencies": {
    ...
    "@acme/api": "*",
    "@acme/design": "*"
    ...
  }
  ...
}

Now, update blog/pages/index.tsx to use the Layout component:

import * as React from "react";
import { NextStatelessComponent } from "next";
import Link from "next/link";
import { Models } from "@acme/api/server";
import { apiClient } from "@acme/api/client";
import { Layout } from "@acme/design/components";

interface Props {
  posts: Models.Post[];
}

const BlogIndex: NextStatelessComponent<Props> = ({ posts }) => {
  return (
    <Layout>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link passHref href={`${post.id}`}>
              <a>{post.title}</a>
            </Link>
          </li>
        ))}
      </ul>
    </Layout>
  );
};

BlogIndex.getInitialProps = async () => {
  const posts = await apiClient.posts.getListing();
  return { posts };
};

export default BlogIndex;

Hot reloading

We're not quite done yet! If you run the development server inside the blog application and make a change to a component in the design workspace, hot reloading does not work. The reason for this is that Webpack does not expose a configuration option for resolving symlinks to their full path, which means that changes inside the source directory do not trigger a reload in `WebpackDevServer``. You can read more in this issue.

To fix this, we'll add a super hacky postinstall script in our root level package.json file:

{
  ...
  "scripts": {
    "postinstall": "sed -i 's/followSymlinks: false/followSymlinks: true/g' node_modules/watchpack/lib/DirectoryWatcher.js"
  }
  ...
}

It works but... ๐Ÿคฎ Yuck! We could also have implemented this with a .patch file


Wrapping up

This post has been full of tricks, hacks and monkey patches, however once everything's set up, working with Next.js and workspaces is a truly delightful developer experience.

I've set up an example repository that accompanies this post which you can use as a boilerplate if you need to.

If you have any questions or problems, feel free to open an issue in GitHub or catch me on Spectrum and I'll do my best to help you out.