Publish a Typescript React library to NPM in a monorepo
In this blog post we create a small React library and publish it to NPM.
We'll go through all steps required to do that.
Let's begin.
Overview
The library we will publish (package
from now on) is written in Typescript, bundled with tsup
, versioned and published with changeset
.
To test the package we create one example app with Next.js
and one with Vite
.
To better manage these three apps (our package + Next.js demo + Vite demo) we store everything in a monorepo, using turborepo
.
The Package
It will be named use-last-visit-date
and will contain a React hook used to:
Retrieve date of last visit, that was previously saved to localStorage.
Tutorial
1. Create a monorepo
This guide will not cover how a monorepo works, if this concept is new to you can read a little about it to get the idea.
If you want to skip that, you should know that for this guide i opted to monorepo only to be able to have the package itself and demo apps in the same codebase.
Otherwise i should have created 3 separate repo.
Let's go!
Setup
Tool used: turborepo
, pnpm
To create a new monorepo, use create-turbo
and follow the prompt.
NOTE:
If you don'havepnpm
installed you can install it following installation guide
You can also useyarn
ornpm
if you want.
# create a monorepo
npx create-turbo@latest
# answers
? Where would you like to create your turborepo? ./use-last-visit-date
? Which package manager do you want to use? pnpm
Turborepo will init the monorepo and print
>>> Created a new turborepo with the following:
- apps/web: Next.js with TypeScript
- apps/docs: Next.js with TypeScript
- packages/ui: Shared React component library
- packages/eslint-config-custom: Shared configuration (ESLint)
- packages/tsconfig: Shared TypeScript `tsconfig.json`
>>> Success! Created a new Turborepo at "use-last-visit-date".
Good to know
turborepo
expose an executable turbo
that can fire a script in each workspace with a specific name.
For instance, running turbo build
from root of monorepo will execute build
script in every workspace.
If a workspace hasn't a build
script, that workspace will be ignored.
In which order they are executed ?
turbo.json
is the file where this configuration takes place.
Default setting will be enough for this guide, but i encourage you to read documentation later on.
Clean up
Rename root
package.json
name
to "root-monorepo" to avoid collision with package we are going to create.Delete
apps/docs
directory, we'll not use it
2. Create package workspace
To create our package run :
# go to root of the monorepo
cd ./use-last-visit-date
# create package directory
mkdir packages/use-last-visit-date
cd pacakges/use-last-visit-date
# init the package with pnpm
pnpm init
# install needed dev dependencies
pnpm add -D react react-dom @types/react @types/react-dom
Add tsconfig
We are using typescript so we need a tsconfig
file.
Fortunately, turborepo
already created a shared tsconfig internal package for us.
Inside there are various configuraion ready to be extended.
This is one of the beauties of using a tool like turborepo
.
Add tsconfig
as a dev dependency
# in packages/use-last-visit-date/package.json
"devDependencies": {
+ "tsconfig": "workspace:*",
}
Reinstall dependencies in whole monorepo
pnpm install
Create a tsconfig.json
in packages/use-last-visit-date
with this content
{
"extends": "tsconfig/base.json",
"include": ["."],
"exclude": ["dist", "node_modules"],
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"]
}
}
Write code
Create the file which will contain our code
# run this from packages/use-last-visit-date
touch ./index.ts
and fill with
import { useEffect, useState } from 'react';
const storage = {
key: 'last-visit-date',
save: (value: string) => window.localStorage.setItem(storage.key, value),
retrieve: () => window.localStorage.getItem(storage.key) ?? null,
}
export const useLastVisitDate = () => {
const [lastVisitDate, setLastVisitDate] = useState<null | string>(null);
// On page load retrieve the last visit date if exists
useEffect(() => {
const value = storage.retrieve();
setLastVisitDate(value);
}, []);
// On page close save current date
useEffect(() => {
const handler = () => {
const currentDate = new Date().toISOString();
storage.save(currentDate);
}
window.addEventListener('beforeunload', handler);
return () => {
window.removeEventListener('beforeunload', handler);
}
}, []);
return lastVisitDate;
}
Then update which file will be exported by opening packages/use-last-visit-date/package.json
and
- "main": "index.js",
+ "main": "./index.ts",
It's time to test our code in a real react app.
3. Create demo app workspace - Next
turborepo
has already created a Next.js app in apps/web
so there is no need to do it.
Because later on we will create also a Vite app, let's add a better name to this Next.js app.
Rename apps/web
dir to apps/next
and update apps/next/package.json
:
- "name": "web",
+ "name": "next",
Then consume the useLastVisitDate
hook in pages/index.tsx
const formatDate = (d: string) => {
const date = new Date(d);
return date.toLocaleString();
};
export default function Page() {
const lastVisitDate = useLastVisitDate();
return (
<div>
<h1>
{lastVisitDate === null && "Hello, it's first time you visit us !"}
{lastVisitDate && `Last Visit: ${formatDate(lastVisitDate)}`}
</h1>
</div>
);
}
But this will throw an error because useLastVisitDate
is not defined, and it's correct because we haven't imported it yet.
Before we can import it we must add the use-last-visit-date
to dependencies of apps/next
.
So update apps/next/package.json
:
"dependencies": {
- "ui": "workspace:*"
+ "use-last-visit-date": "workspace:*"
}
and tell pnpm to "refresh" monorepo dependencies by running
# is not important from "where" you run this command
pnpm install
Now you can import the hook in pages/index.tsx
// Add at the top
import { useLastVisitDate } from 'use-last-visit-date';
and errors should disappears.
NOTE:
If this is not happening, try Restarting TS server in your code editor.
Now it's time to test if it's working.
# run this from apps/next
pnpm dev
And open http://localhost:3000.
You should see
Hello, it's first time you visit us !
Then refresh the page and text should be changed to
Last Visit: ......
4. Create demo app workspace - Vite
Move with shell to root of monorepo and
# go to apps dir
cd apps
# create a vite workspace
pnpm create vite vite-react --template react-ts
# install dependencies
pnpm install
Then consume the useLastVisitDate
hook in src/App.tsx
const formatDate = (d: string) => {
const date = new Date(d);
return date.toLocaleString();
};
export default function Page() {
const lastVisitDate = useLastVisitDate();
return (
<div>
<h1>
{lastVisitDate === null && "Hello, it's first time you visit us !"}
{lastVisitDate && `Last Visit: ${formatDate(lastVisitDate)}`}
</h1>
</div>
);
}
But this will throw an error because useLastVisitDate
is not defined, and it's correct because we haven't imported it yet.
Before we can import it we must add the use-last-visit-date
to dependencies of apps/vite-react
.
So update apps/vite-react/package.json
:
"dependencies": {
...other-deps,
+ "use-last-visit-date": "workspace:*"
}
and tell pnpm to "refresh" monorepo dependencies by running
# is not important from "where" you run this command
pnpm install
Now you can import the hook in src/App.tsx
// Add at the top
import { useLastVisitDate } from 'use-last-visit-date';
and errors should disappears.
NOTE:
If this is not happening, try Restarting TS server in your code editor.
Now it's time to test if it's working.
# run this from apps/vite-react
pnpm dev
And open http://localhost:5173.
You should see
Hello, it's first time you visit us !
Then refresh the page and text should be changed to
Last Visit: ......
5. Time to publish
Overview
Now, use-last-visit-date
is an internal packages, it's written in Typescript and it works because Next.js app and Vite app take care of compiling the code to js.
But our code should run also if these assumptions are not in place.
We need to compile to js.
Then we can publish to NPM.
To recap, publish a package to NPM consists of two steps:
- Bundle and compile source code to
commonjs
, so who install our packge will be able to import it - Set up a dev script for local development.
- Publish to NPM
- Control versioning
- Control publishing
Bundle and Compile
Tools used: tsup
First, go with shell to packages/use-last-visit-date
and
# install tsup as dev dependency
pnpm add -D tsup
# create a src dir
mkdir src
# move index.ts to src dir
mv index.ts src/index.ts
Then add a script to packages.json
in packages/use-last-visit-date
{
"scripts": {
+ "build": "tsup src/index.ts --format cjs --dts"
}
}
This script,will use tsup
to bundle and compile our code, and outputs files to the dist
directory.
Because of that, you should:
- add
dist
to your.gitignore
at root of monorepo to avoid push it whengit push
ing. - update
package.json
main
property, so who install this packages will use the bundled+compiled version of code.
# in packages/use-last-visit-date/package.json
- "main": "./index.ts",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.js",
Because this package is a react package, while developing we need react
and react-dom
installed as dev dependencies.
But we don't want to inject the whole react
code inside our package when it's bundled and compiled.
What we want is to let consumer of package install it.
So, add peer dependencies to package.json
in packages/use-last-visit-date
+ "peerDependencies": {
+ "react": ">=18.2.0",
+ "react-dom": ">=18.2.0"
+ },
Set up workflow for local development
Before adding tsup
, apps inside apps
directory was importing directly the source code file (with .ts extension).
So, when we updated the source code file, our apps
received the updates.
Now we added a compile step in between, so we need to trigger a new build
every time we update source code.
To do that, update package.json
in packages/use-last-visit-date
{
"scripts": {
"build": "tsup src/index.ts --format cjs --dts"
+ "dev": "pnpm run build --watch"
}
}
From now on, while developing, we'll use dev
script.
Let's test a complete local development workflow.
Go to root of monorepo with shell and run
pnpm dev
This will trigger dev
script of every workspace that has a dev
script, in our case:
packages/use-last-visit-date
apps/next
, available at http://localhost:3000apps/vite-react
, available at http://localhost:5173
Try to change something in use-last-visit-date
code and watch updates.
Publish to NPM
Every time you update source code, you want that this changes will be published as a new version.
You want also a place where to condense description of changes linked to every new version.
You can do it manually, but is very tedious and error prone. Better to use a tool created for this.
This is when changeset
enter the pitch.
Configure changeset
From root of the monorepo
# install changeset
pnpm add -w @changesets/cli
# setup
pnpm changeset init
Create 1.0.0 version
Set version
to 0.0.1
in packages/use-last-visit-date/package.json
"version": "0.0.1"
Then use the versionig tool of changeset
# run this from root monorepo
pnpm changeset
# a prompt start
? Which packages would you like to include?
# select "use-last-visit-date" with arrows and space
# then press enter
? Which packages should have a major bump?
# select "use-last-visit-date" with arrows and space
# then press enter
? Are you sure you want to release the first major version of use-last-visit-date?
# confirm it
Please enter a summary for this change (this will be in the changelogs).
# write a description
# like "First release"
# then press enter
?Is this your desired changeset?
# confirm it
# bump version number
# running
pnpm changeset version
Now version
should be 1.0.0
.
Publish
Before publish check that every packages in your monorepo that MUST NOT be published has
"private": true
in its package.json
.
This prevent them to be published to NPM.
Then update ./.changeset/config.json
- "access": "restricted",
+ "access": "public",
And.... Finally... We can publish our package
pnpm changeset publish
Done!
Conclusion
I write articles mainly to help future myself or to help the growth of tools I use in my work.
If this article was helpful to you leave a like.
Would you like me to talk about a particular topic ?
Tell me in the comments !
This post is cross-posted here, where you will find comments.