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.
If you prefer to jump straight in to the code, you can visit the example repository that accompanies this post.
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.
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.
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.
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
...
}
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.
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.
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 theyarn.lock
file is somewhere else in the directory structure, something has gone wrong!
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 apages
directory. Please create one under the project root".
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?
So Next.js has an issue picking up the index.tsx
file we just made. Clearly there's some work to be done.
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.
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 - thebabel.config.js
is intended to be shared across projects, you can read more about thebabel.config.js
file here.
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.
First milestone done and dusted! 🎉🎉🎉
yarn dev
Visit http://localhost:3000.
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.
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"
]
...
}
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
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"
forts-node
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!
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.
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;
},
},
};
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!?
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.
Another milestone covered! 🎉🎉🎉
Go ahead and restart the blog application and visit http://localhost:3000.
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 😉
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.
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>
);
}
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;
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
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.