Typescript and Next.js include another project's *source code* only from outside module root?

632 Views Asked by At

Summary

I have a closed source Next.js site with Typescript. I'm trying to import a few React components from an external open source Next.js project (that I also own). I'm trying to do this in a way where node_modules are shared, or entirely from the closed source project, and also so that I get hot reloading in Next.js when I modify files in the external project.

I'm struggling to find a combination of tsconfig.json and next.config.js / webpack.config that achieves what I want.

Problem Detail

I have two Next.js projects with Typescript:

  • "A" which is closed source. This is a Next.js site with Typescript. It's closed source because this site has authentication, security etc in it. This project imports from project B.
  • "B" which is open source. This project mainly contains a large Editor component (think a code editor). I want this project to be open source because I want the Editor to be open source. For convenience, this project also is a Next.js app, so that you can run a server with hot reloading on it locally to develop on the editor without requiring access to the closed source project.

The folder structure is something like

A/ (closed source)
  node_modules/
  tsconfig.json
  next.config.jstsx
  pages/
    index.tsx
B/ (open source)
  node_modules/
  tsconfig.json
  next.config.js
  components/
    Editor.tsx
  pages/
    index.tsx

And ultimately, what I'd like to do, is that in project A be able to import files from project B, as if they were local to project A:

import Editor from '../../B/components/Editor.tsx`

Such that:

  • When I run the Next.js app in project A, and I modify the Editor component in project B, the Next.js site hot reloads the changes
  • When Editor.tsx does something like import React from 'react';, it uses the dependencies from project A, to avoid bundling two versions of React into the local site.

Editor.tsx in project B also imports some Node modules that aren't currently in project A, like some third party React libraries. Ideally I would like the Next.js site running in A to figure out that those dependencies need to come from B/node_modules, while common libraries like React come from A/node_modules, but I'm also fine forcing A to have to install those same dependencies.

Where I'm having trouble with this is finding the right combination of next.config.js and tsconfig.json to make this work, and it's not clear to me how Next.js's hot reloading and loading interplay with Typescript's external dependencies.

What I've tried most recently is putting this in my next.config.js:

  transpilePackages: [
    path.resolve(__dirname, '../B'),
  ],

And this in my tsconfig.json:

  "references": [
    { "path": "../B" }
  ],

To test this is doing what I want, I rm -rf node_modules in project B, to make sure those node_modules aren't being used.

But in my A site, when I import ../B/components/Editor.tsx, I get:

- error ../B/src/plugins/Editor.tsx
Module not found: Can't resolve 'react/jsx-dev-runtime'

This tells me that Next.js is trying to use React from B/node_modules, even though React is installed in A/

I have tried a few configurations of tsconfig's rootDirs and paths, as well as next.config.js's transpileModules', as well as trying to modify the webpack.config.rules and webpack.config.snapshot.managedPaths and webpack.config.watchOptions. But it's not clear to me how all of these play together (or don't), especially given the interplay of Typescript and Webpack.

Sorry for the long question, I'm trying to lay out my assumptions in case I have something wrong, or you see a creative solution to my needs.

1

There are 1 best solutions below

0
nameofname On

This is a very specific request. The way I see it there are 2 options, but the module federation option is probably what you really want here... I'll get to that.

Option #1 : Package B is a dependency of Package A Seems to me the problem with the setup you have above is that you're trying to use a component as a dependency project B, who's root is outside the root of project A... this doesn't work because module resolution for project B looks inside of the node_modules directory for project B.

The solve is to have project B inside the node_modules of project A.

There's a problem with this approach though, project B is a next JS project, so it's not really configured to export the Editor component. To get around this, you could add a build step which runs tsc over project B and then in package.json expose Editor like so :

// package.json
{ ...
  "exports": {
    "./Editor": "./dist/path/to/Editor.js"
  }
...
}

However this is kind of a mess IMO, which leads me to what a 'normal' approach for this situation would be...

Option 1A : Editor is a shared module

This is how most people would approach the problem of sharing components between a package, that is, remove Editor to it's own repository. Perhaps you have enough cases like this that it makes sense to have a shared component library, whatever the case, you would create a package like MyComponentLib which becomes a direct dependency of projects A and B.

To me, option 1A is ok, but starts to introduce dependency hell. You can get around that to some degree by placing project A and B together in a monorepo, but that's a whole other conversation, which brings me to...

Option 2 : Module federation

Module federation is a Webpack 5 feature which allows you to import modules from a remote build. Conceptually, project A would have it's own (next.js) build, and project B would have a separate build (as they do now). Using module federation you could expose the Editor component as a federated module from project B, and configure project A to consume that.

For use in next.js, you need the nextjs-mf plugin.

// next.config.js for project B
/** @type {import('next').NextConfig} */
const NextFederationPlugin = require('@module-federation/nextjs-mf');

module.exports = {
    webpack(config, options) {
        ...,
        config.plugins.push(
            new NextFederationPlugin({
                name: 'ProjectB',
                filename: 'static/chunks/remoteEntry.js',
                shared: { ... },
                exposes: {
                    './Editor': './src/path/Editor.tsx',
                },
                ...
            })
        );
    }

Check the examples in the package readme on how to configure project A. Note, the shared property of the NextFederationPlugin options allows you to specify which dependencies to share between projects A and B, which satisfies your requirement to not bundle duplicate dependencies. Note that react and a few other things are shared by default using this plugin (see DEFAULT_SHARE_SCOPE).

Caveats:

  • One drawback is that hot reloading does not work, sorry.
  • Nextjs-mf plugin does not currently support Next.js 13's app router (this is very frustrating IMO).

BUT, what's great about this approach is that whenever you build and deploy project B, project A will pick up those changes automatically, no need to deploy project A at all!