Building internal packages with Preconstruct
This lesson preview is part of the Bundling and Automation in Monorepos course and can be unlocked immediately with a \newline Pro subscription or a single-time purchase. Already have access to this course? Log in here.
Get unlimited access to Bundling and Automation in Monorepos, plus 90+ \newline books, guides and courses with the \newline Pro subscription.

[00:00 - 00:11] In this lesson, we're going to add Preconstruct to our monorepo. In previous lessons, we saw how we can share code from our internal packages using direct exports of TypeScript.
[00:12 - 00:21] However, that setup is a bit brittle and we do want to also be able to build packages. So the tool that I'm using here is Preconstruct.
[00:22 - 00:44] This is a tool that wraps other tools, more specifically Rollup and Babel and is specifically designed around multi-package workspaces or Monorepos. The requirements for our choice is that we want to that is going to author package.json exports for us, so we don't need to write those manually.
[00:45 - 01:22] It must have a dev mode that exports the TypeScript source code directly for better dev experience while we're building our apps. It must be able to output optimized production builds that still correctly link back to our internal source code in editors, and it must support and build for custom export conditions like "browser", "react-server", "production" and "development". Now why not use Vite, ESBuild or tsup? All these tools have their niche usage.
[01:23 - 01:32] We already are using Vite and Next.js which internally rely on ESBuild and Webpack. We are using tsup for our node server.
[01:33 - 01:57] However, these are tools that are meant for applications and are not ideal for building shared packages across a monorepo. There are other build tools that are meant for that. tsup can be wrangled into that tsdown as well, bunchee is a popular choice right now, but unfortunately they lack good dev ergonomics for sharing code.
[01:58 - 02:07] You must run a build script after every code change with them. Most, with the exception of bunchee, don't handle package.json exports for us.
[02:08 - 02:22] All three of those do not emit TypeScript declaration source maps and the handling of advanced cases like custom export conditions is a bit spotty. Peconstruct is something that I have used in production for a long time.
[02:23 - 02:32] It has a CLI that will generate the exports field for your package. It has a dev mode that will symlink files directly to source.
[02:33 - 02:58] It supports multiple entry points. It interfaces with Rollup and Babel, which means any configuration we can give to Babel, it can build, which gives us compatibility with almost any code you want to write. And it does .d.ts source mapping, which means production builds correctly resolve and jump to our internal source even in production builds.
[02:59 - 03:09] Some projects that use Preconstruct: XStatee, react-select and vanilla-extract are large well-known projects that make use of it. And what does it mean in practice?
[03:10 - 03:20] We'll need to add our setup for Preconstruct, but once we do that, you would write TypeScript as normal. Consumers of internal packages would import them as normal.
[03:21 - 03:39] The changes to shared code would be instantly visible as they are with direct exports of TypeScript, and we would have actually optimized production builds that we can publish if we need to publish our internal packages in the future. With that out of the way, let's just get to it.
[03:40 - 04:07] I'm going to go into our packages/ui package that, just as a reminder, just has a single button, a single react component, that's a button, exported. Right now in our package.json, we exported directly as the source TypeScript file. What I'm going to do is I'm going to I'm going to "pnpm add @preconstruct/cli" at a specific version. Right now.
[04:08 - 04:20] 2.8.12 is the latest version, and I'm also going to add @babel/preset-typescript. And add @babel/preset-react.
[04:22 - 04:38] I'm going to create a babel.config.json file. And I'm going to paste our config which is just a @babel/preset-typescript and @nabel/preset-react with the automatic runtime.
[04:39 - 05:10] I'm going to save that file and then open our package.json. We're going to add type: module to our package.json and then add our Preconstruct configuration. The important part here is that we set entrypoints relative to our "src" folder. So we're saying "src/button/index.tsx" Is one of our entrypoints. We're using the exports: true option, which means Preconstruct is going to handle the exports field for us.
[05:11 - 05:31] And these three settings in combination with type: module means that Preconstruct is going to emit ESM only code. We want this because if we are emitting dial packages with CJS and ESM, that gives a lot of extra complexity that we don't want to be dealing with.
[05:32 - 06:08] For any modern code base, it's fine to emit ESM only and not emit CJS, and that's the configuration that we're going to be using. I'm going to add a "build" script that's just going to be "preconstruct build". And then I want to generate our exports. To do that I'm going to go down to the command line, do "pnpm preconstruct fix" and it's going to tell me that we're missing the "dist" directory in our root package config. So to add it we add the following configuration.
[06:09 - 06:19] We add "files: ["dist"]. This means when this package is published to npm, the dist folder is what's going to get published.
[06:20 - 06:32] Preconstruct needs the setting in order to be able to generate the files correctly in the dist folder. Back to the command line, try "preconstruct fix" again. Now it says fixed project.
[06:33 - 06:48] If I go back to my package.json, we see that it changed our exports. The export now points to a file in dist and also it added the "package.json" export, which is always a good thing to have in your configuration.
[06:49 - 07:04] Now if I open the "dist" folder right now, the only thing in here is going to be preconstruct-test-file that was written when we run "preconstruct fix" just to verify that it can write to this folder. There are two commands that we can use from here.
[07:05 - 07:10] One is "preconstruct build" and the other is "preconstruct dev". I'm first going to show you what "preconstruct dev" does.
[07:11 - 07:27] "preconstruct dev" created two files in the dist folder. One is a js file that points to our source code and the other is a .d.ts file. So let's look at their contents.
[07:28 - 07:46] The JS file, as we said, is just a symlink. It directly points to the contents of our source code, while the .d.ts file just re-exports our source code types and it has a source map.
[07:47 - 08:04] If I open our front end Next.js app and go to our button, the editor can directly jump to the source file. So that works as before, when we had a direct export of the source file and changes to the source are going to be propagated.
[08:05 - 08:24] So if I open our button and add this changed line, it does show up immediately here, in our dev server. Next let's see what what "pnpm preconstruct build" does.
[08:25 - 08:34] This is going to do a slightly different thing. So now we have again our .Js file and our .d.ts files in the dist root.
[08:35 - 09:12] And then we have this decorations folder that maps to the folder structure of our source directory. If we open our compiled component we're going to see that it correctly uses the modern JSX runtime. And we have optimized ESM code emitted. If I open the .d.ts file, its source mapping to the decorations file, and the decorations down here emit the types of the button, which is just a react element.
[09:13 - 09:27] We can add slightly more complicated types just to see how those would be built. So let me do that. I've added a type for button props that takes an optional initial count, and we either use this initial count or we use zero.
[09:28 - 09:37] I'm going to build again. And now we see that the types generated in our export are correct.
[09:38 - 10:00] We have our props exported and if I go to our dist .js file, it has been correctly built and props is being used. Now we have our optimized build, and the last step to get this to correctly map to our source code is to change our TSConfig.
[10:01 - 10:13] I'm going to open tsconfig.json and add an option called "declarationMap". Set that to true, then do "pnpm preconstruct build" again.
[10:14 - 10:22] And if we look at our dist folder. We have now generated a .d.ts map file for our button.
[10:23 - 11:05] This means that if I go to our Next.js app and open up page TSX and look at our button, if I go to the source of the button to the decoration of the button, it directly goes to the source code of our UI package button. So even though we have a production build of this button, the types are correctly mapping all the way to the source code, and actual builds in Next.js are going to use our optimized production build in our UI package. Now in our scripts here, we added the build script.
[11:06 - 11:17] We should also add a dev script and that's again going to just be "preconstruct dev". So now "pnpm dev" gives us the symlinked version.
[11:18 - 11:31] Now the last thing left to do is to add our changes. We added a Babel config in our UI package.
[11:32 - 11:46] We did all the package.json changes to add the configuration for preconstruct. We updated our button with props and the rest is just pnpm lock changes.
[11:47 - 12:12] We are going to commit our changes as "Add preconstruct for packages/ui" and Knip is actually going to complain. Previously it detected our button index file as an entrypoint because it was directly exported. Now that we don't directly export it, we actually need to list it as an entry point in our configuration.
[12:13 - 12:41] So let's do that. I'm going to add our packages/ui, and then under entry I'm going to say anything that's in a directory in our src directory and is an index.ts file. I'm going to do the same change in our packages ui package.json in our preconstruct entrypoint definitions I'm going to say top folder in src and then index.tsx should all be entry points for preconstruct.
[12:42 - 12:57] So that way we don't need to enumerate all components that we're exporting as we add new components to this package. Quit. "pnpm knip" to verify that everything is fine and it is.
[12:58 - 13:15] "git add -p" our change to knip.json to use this entry point, and our change to Preconstruct to directly import all entry points that are in src one folder deep, and then index.tsx. With this I'm going to commit.
[13:16 - 13:21] And our lesson is done. See you in the next one.