This is an alpha, sneak peek of Monorepo Maestros. For this iteration, I'm getting all of my thoughts down. In the future, we'll have better information architecture, graphics, and other awesomeness. Your feedback is welcome!
Internal Package
Internal Packages are similar to External Packages in many ways:
- Compiled: The package is compiled from its source code.
- Specified entrypoint(s): The package has explicit places for you to access the functionality that the package provides.
- Package manager-friendly: Installing packages with your package manager is an easy way to share code.
However, Internal Packages are different in two important ways:
- In-repo source code: The code that makes up the package is in your repository, making it highly discoverable, accessible, and editable.
- One version: When installing to other workspaces within your repository, you'll use
"*"
(or "workspace:*"
for pnpm). This means you'll always use whatever the current version of the code that is in your repository.
When you compile a package in your Turborepo, you can cache your compilation outputs for sub-second build times. Build your source code once; never build it again.
Let's take a look at what it takes to build an Internal Package for UI component library with React and TypeScript.
We first need to lay the foundation for our workspace. We can do so in a few quick steps.
Establish a workspace by following the workspace instructions for your package manager.
We'll be making a TypeScript package so we need to have a tsconfig.json
. If you've read the Typescript reference already, this will look familiar:
packages/ui/tsconfig.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@repo/tsconfig/react-library.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"outDir": "dist"
},
"include": ["."],
"exclude": ["dist", "node_modules"]
}
packages/ui/tsconfig.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@repo/tsconfig/react-library.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"outDir": "dist"
},
"include": ["."],
"exclude": ["dist", "node_modules"]
}
packages/ui/tsconfig.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@repo/tsconfig/react-library.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"outDir": "dist"
},
"include": ["."],
"exclude": ["dist", "node_modules"]
}
packages/ui/tsconfig.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@repo/tsconfig/react-library.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"outDir": "dist"
},
"include": ["."],
"exclude": ["dist", "node_modules"]
}
There are a few important properties that you'll want to make sure you include in either your base tsconfig
or here in your package's tsconfig.json
to have the smoothest experience:
"declaration": true
: Create .d.ts
files for your compiled JavaScript so your package has types when imported by other workspaces.
"declarationMap": true
: Generates maps between your type declarations and JavaScript files. This allows your editor to jump to the source code rather than your .d.ts
file when you use Go To Definition.
"outDir": "dist"
: The location where your TypeScript declaration files will be created. You want this to be the same as where your .
All of these configurations will add up to a perfect TypeScript experience once we get to our compilation step.
Create a package.json
in your workspace directory. We'll fill in "exports"
and "scripts"
step-by-step later so we can understand what they do, piece-by-piece.
{
"name": "@repo/ui",
"version": "0.1.0", // Arbitrary for Internal Packages
"private": true, // Prevents npm publishing
"exports": {}, // Entrypoints for your application
"scripts": {}, // Tasks for the package
"dependencies": {
"react": "^18.2.0"
},
"devDependencies": {
"@repo/tsconfig": "workspace:*",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.0",
"tsup": "^7.2.0", // Bundler for compilation
"typescript": "^5.1.6"
}
}
{
"name": "@repo/ui",
"version": "0.1.0", // Arbitrary for Internal Packages
"private": true, // Prevents npm publishing
"exports": {}, // Entrypoints for your application
"scripts": {}, // Tasks for the package
"dependencies": {
"react": "^18.2.0"
},
"devDependencies": {
"@repo/tsconfig": "workspace:*",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.0",
"tsup": "^7.2.0", // Bundler for compilation
"typescript": "^5.1.6"
}
}
{
"name": "@repo/ui",
"version": "0.1.0", // Arbitrary for Internal Packages
"private": true, // Prevents npm publishing
"exports": {}, // Entrypoints for your application
"scripts": {}, // Tasks for the package
"dependencies": {
"react": "^18.2.0"
},
"devDependencies": {
"@repo/tsconfig": "workspace:*",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.0",
"tsup": "^7.2.0", // Bundler for compilation
"typescript": "^5.1.6"
}
}
{
"name": "@repo/ui",
"version": "0.1.0", // Arbitrary for Internal Packages
"private": true, // Prevents npm publishing
"exports": {}, // Entrypoints for your application
"scripts": {}, // Tasks for the package
"dependencies": {
"react": "^18.2.0"
},
"devDependencies": {
"@repo/tsconfig": "workspace:*",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.0",
"tsup": "^7.2.0", // Bundler for compilation
"typescript": "^5.1.6"
}
}
At this point, hit the install command for your package manager to get the packages installed.
We'll use an src
folder in our workspace to keep things tidy. Create your first UI component in an index file:
packages/ui/src/index.tsx
export const Button = (props: { text: string }) => {
return <button>{props.text}</button>;
};
packages/ui/src/index.tsx
export const Button = (props: { text: string }) => {
return <button>{props.text}</button>;
};
packages/ui/src/index.tsx
export const Button = (props: { text: string }) => {
return <button>{props.text}</button>;
};
packages/ui/src/index.tsx
export const Button = (props: { text: string }) => {
return <button>{props.text}</button>;
};
We'll need to get our TypeScript compiled to JavaScript so our applications and packages can consume our code. For this, we like to use tsup
, a bundler that gives us a simple yet powerful bundling API with esbuild
under the hood.
We want to be able to compile our package the same way whether we are building in watch mode for development or building for production. tsup
can read from a tsup.config.js
file to give us a consistent configuration to use:
packages/ui/tsup.config.js
import { defineConfig } from 'tsup';
import { exec } from 'child_process';
export default defineConfig((options) => ({
entry: ['src/index.tsx'], // Where your source code lives (can provide multiple entries)
splitting: false,
treeshake: true,
clean: true,
outDir: 'dist', // Where you want your compiled files to live
onSuccess: async () => {
exec('tsc --emitDeclarationOnly');
},
...options,
}));
packages/ui/tsup.config.js
import { defineConfig } from 'tsup';
import { exec } from 'child_process';
export default defineConfig((options) => ({
entry: ['src/index.tsx'], // Where your source code lives (can provide multiple entries)
splitting: false,
treeshake: true,
clean: true,
outDir: 'dist', // Where you want your compiled files to live
onSuccess: async () => {
exec('tsc --emitDeclarationOnly');
},
...options,
}));
Note: We're using JavaScript here but will still get great autocomplete. 😄
packages/ui/tsup.config.js
import { defineConfig } from 'tsup';
import { exec } from 'child_process';
export default defineConfig((options) => ({
entry: ['src/index.tsx'], // Where your source code lives (can provide multiple entries)
splitting: false,
treeshake: true,
clean: true,
outDir: 'dist', // Where you want your compiled files to live
onSuccess: async () => {
exec('tsc --emitDeclarationOnly');
},
...options,
}));
packages/ui/tsup.config.js
import { defineConfig } from 'tsup';
import { exec } from 'child_process';
export default defineConfig((options) => ({
entry: ['src/index.tsx'], // Where your source code lives (can provide multiple entries)
splitting: false,
treeshake: true,
clean: true,
outDir: 'dist', // Where you want your compiled files to live
onSuccess: async () => {
exec('tsc --emitDeclarationOnly');
},
...options,
}));
Note: We're using JavaScript here but will still get great autocomplete. 😄
Notice the important onSuccess
function here. It's responsible for giving us TypeScript declaration files so that our editor will jump to our TypeScript source code instead of our compiled JavaScript. tsup is unable to generate these files but, luckily, the TypeScript compiler handles these pretty quickly.
Head back to your package.json
and add some scripts:
{
"name": "@repo/ui",
"version": "0.1.0", // Arbitrary for Internal Packages
"private": true, // Prevents npm publishing
"exports": {}, // Entry points for your application
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
},
"dependencies": {...},
"devDependencies": {...}
}
{
"name": "@repo/ui",
"version": "0.1.0", // Arbitrary for Internal Packages
"private": true, // Prevents npm publishing
"exports": {}, // Entry points for your application
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
},
"dependencies": {...},
"devDependencies": {...}
}
{
"name": "@repo/ui",
"version": "0.1.0", // Arbitrary for Internal Packages
"private": true, // Prevents npm publishing
"exports": {}, // Entry points for your application
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
},
"dependencies": {...},
"devDependencies": {...}
}
{
"name": "@repo/ui",
"version": "0.1.0", // Arbitrary for Internal Packages
"private": true, // Prevents npm publishing
"exports": {}, // Entry points for your application
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
},
"dependencies": {...},
"devDependencies": {...}
}
You'll want to make sure you aren't committing your builds to source control. If you haven't done so yet, add dist
to your .gitignore
:
exports
is where we define the entry points to our package. Workspaces that consume your Internal Package will use these as the places that they can import code from.
To create the entrypoint, we'll fill in the "exports"
field of the package.json
:
{
"name": "@repo/ui",
"version": "0.1.0",
"private": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup -w"
},
"dependencies": {...},
"devDependencies": {...}
}
{
"name": "@repo/ui",
"version": "0.1.0",
"private": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup -w"
},
"dependencies": {...},
"devDependencies": {...}
}
{
"name": "@repo/ui",
"version": "0.1.0",
"private": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup -w"
},
"dependencies": {...},
"devDependencies": {...}
}
{
"name": "@repo/ui",
"version": "0.1.0",
"private": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup -w"
},
"dependencies": {...},
"devDependencies": {...}
}
We should be ready now! Run turbo build --filter=ui
in your terminal and check your new ui
package to find a dist
folder full of .d.ts
and .js
files.
We'll now be able to import our code in an application once we install it to its workspace:
import { Button } from '@repo/ui';
import { Button } from '@repo/ui';
import { Button } from '@repo/ui';
import { Button } from '@repo/ui';
You'll now be able to continually update this package much faster than if you needed to publish it to npm since it will be right there alongside the rest of the code for your applications. If you, for example, change a TypeScript interface in your UI package, you'll instantly be able to find any mistypings in your application the next time you run turbo typecheck
.
If you'd like to build conformance into your new UI package, be sure to follow along with the references in the Guardrails pages.