engineering grouparoo next.js node.js react
2020-07-23 - Originally posted at https://www.grouparoo.com/blog/nextjs-plugins
↞ See all posts
At Grouparoo, our front-end website is built using React and Next.js. Next.js is an excellent tool made by Vercel that handles all the hard parts of making a React app for you - Routing, Server-side Rendering, Page Hydration and more. It includes a simple starting place to build your routes and pages, based on the file system. If you want a /about
page, just make an /pages/about.tsx
file!
The Grouparoo ecosystem contains many ways to extend the main Grouparoo application through plugins. Part of what Grouparoo plugins can do is add new pages to the UI, or add new components to existing pages. We use Next.js to build our front-end... which is very opinionated in its default settings to only work with "local" files and pages. How then can we use Next.js to load pages and components from other locations like plugins? In this post, we’ll talk about how to load additional components and pages from a sub-project, like a lerna monorepo, or a package released to NPM.
We have a monorepo, which we will be using Lerna to manage. We have a server
project which is our main application and plugins
which contain plugins the server
can use. The plugin, my-nextjs-plugin
contains a page, /pages/hello.tsx
, which we want the main application to display. See the repository here.
Our learna.json
looks like this:
1// lerna.json 2{ 3 "packages": ["plugins/*", "server"], 4 "version": "0.0.1" 5}
Our top-level package.json
contains only lerna
and some scripts that allow us to run lerna bootstrap
as part of the top-level install process and helpers to run dev
and start
for us in the main server
project.
1// package.json 2{ 3 "name": "next-plugins", 4 "version": "0.0.1", 5 "description": "An example of how to use a dynamic import to load a page from a random plugin outside of the main next \"pages\" directory", 6 "private": true, 7 "dependencies": { 8 "lerna": "^3.22.1" 9 }, 10 "scripts": { 11 "start": "cd server && npm run start", 12 "dev": "cd server && npm run dev", 13 "test": "cd server && npm run build", 14 "prepare": "lerna bootstrap --strict" 15 } 16}
This configuration means that when you type npm install
at the top-level of this project, the following will happen:
lerna bootstrap
will be run, which in turn:
npm install
in each child project (server
and plugins
)plugins
into the server
project.npm prepare
lifecycle hooks for each sub-project, which means we can next build
automatically as part of the install process.Our package.json
file for the server can look like:
1// server/package.json 2{ 3 "name": "next-plugins-server", 4 "version": "0.0.1", 5 "description": "I am the server!", 6 "license": "ISC", 7 "private": true, 8 "dependencies": { 9 "my-nextjs-plugin": "0.0.1", 10 "next": "^9.3.2", 11 "react": "^16.13.1", 12 "react-dom": "^16.13.1", 13 "fs-extra": "^9.0.1", 14 "glob": "^7.1.6" 15 }, 16 "scripts": { 17 "dev": "next", 18 "build": "next build", 19 "start": "next start", 20 "prepare": "npm run build" 21 }, 22 "devDependencies": { 23 "@types/node": "^13.7.1", 24 "@types/react": "^16.9.19", 25 "typescript": "^3.7.5" 26 } 27}
And the pacakge.json
from the plugin can look like:
1// plugins/my-nextjs-plugin/package.json 2{ 3 "name": "my-nextjs-plugin", 4 "version": "0.0.1", 5 "description": "I am the plugin!", 6 "main": "index.js", 7 "private": true, 8 "license": "ISC", 9 "dependencies": { 10 "react": "^16.13.1", 11 "react-dom": "^16.13.1" 12 } 13}
Now that the applications are set up, we can add some pages into the server/pages
directory and confirm that everything is working by running npm run dev
.
Next.js has a cool feature that allows you to use files names\d [my-variable].tsx
to indicate a wildcard page route. You can then get the value of my-variable
in your React components. This feature allows us to make a page that handles all the routes we might want to use for our plugins, in this case pages/plugins/[plugin]/[page].tsx
. The page itself doesn’t do much except for handle the routing, which you can see here:
1// server/pages/plugins/[plugin]/[page].tsx 2import dynamic from "next/dynamic"; 3import { useRouter } from "next/router"; 4import Link from "next/link"; 5 6export default function PluginContainerPage() { 7 const router = useRouter(); 8 9 // The Next router might not be ready yet... 10 if (!router?.query?.plugin) return null; 11 if (!router?.query?.page) return null; 12 13 // dynamically load the component 14 const PluginComponent = dynamic( 15 () => 16 import( 17 `./../../../../plugins/${router.query.plugin}/pages/${router.query.page}` 18 ), 19 { 20 loading: () => <p>Loading...</p>, 21 }, 22 ); 23 24 return ( 25 <> 26 <Link href="/"> 27 <a>Back</a> 28 </Link> 29 30 <hr /> 31 32 <PluginComponent /> 33 </> 34 ); 35}
This configuration is how our hello
page from the plugin could be loaded by the route /plugins/my-nextjs-plugin/hello
in the server
application!
Our next step is to extend the Webpack configuration that Next.js provides and use it in our plugins. Next.js comes with all the required tools and configuration for Webpack and Babel to transpile Typescript and TSX (and JSX) pages on the fly... but our plugin doesn’t have access to that because by default, Next.js only includes files within this project for compilation.
In next.config.js
we can extend the Webpack configuration that ships with Next.js to include our plugin:
1// server/next.config.js 2module.exports = { 3 webpack: (config, options) => { 4 config.module.rules.push({ 5 test: /plugins\/.*\.ts?|plugins\/.*\.tsx?/, 6 use: [options.defaultLoaders.babel], 7 }); 8 9 return config; 10 }, 11};
Without this extra Webpack rule, you’ll see compilation or parse errors as the plugins TSX/JSX will not be compiled into browser-usable javascript.
The final piece of the puzzle is give Webpack some help to know where to look for our plugin files. In our pages/plugins/[plugin]/[page].tsx
, we gave Webpack a pretty big area of the filesystem to search with the import(./../../../../plugins/${router.query.plugin}/pages/${router.query.page})
directive. Under the hood, Webpack is looking for all possible files which might match this pattern, in any combination. This search pattern includes cases when one of those paths might be ..
, which may end up scanning a large swath of your filesystem. This approach can be very slow if you have a big project, and lead to out-of-memory errors. Even without crashing, it will make your plugin pages slow to load.
To fix these issues, rather than using wildcards, we can statically reference only the files we’ll need by building “shim” loaders as part of our boot process. We can add require('./plugins.js')
to next.config.js
to make sure that this process happens at boot.
What plugins.js
does is that it loops through all the pages in our plugins and creates a shim in tmp/plugins
for every file we might want to import.
1// server/plugins.js 2const fs = require("fs-extra"); 3const path = require("path"); 4const glob = require("glob"); 5 6// prepare the paths we'll be using and start clean 7if (fs.existsSync(path.join(__dirname, "tmp"))) { 8 fs.rmdirSync(path.join(__dirname, "tmp"), { recursive: true }); 9} 10fs.mkdirpSync(path.join(__dirname, "tmp")); 11 12// the top-level folder needs to exist for webpack to scan, even if there are no plugins 13fs.mkdirpSync(path.join(__dirname, "tmp", "plugin")); 14 15// For every plugin provided, we need to make an file within the core project that has a direct import for it. 16// We do not want to use wildcard strings in the import statement to save webpack from scanning all of our directories. 17const plugins = glob.sync(path.join(__dirname, "..", "plugins", "*")); 18plugins.map((plugin) => { 19 const pluginName = plugin 20 .replace(path.join(__dirname, "..", "plugins"), "") 21 .replace(/\//g, ""); 22 fs.mkdirpSync(path.join(__dirname, "tmp", "plugin", pluginName)); 23 const pluginPages = glob.sync(path.join(plugin, "pages", "*")); 24 pluginPages.map((page) => { 25 const pageName = page 26 .replace(path.join(__dirname, "..", "plugins", pluginName, "pages"), "") 27 .replace(/\//g, ""); 28 fs.writeFileSync( 29 path.join(__dirname, "tmp", "plugin", pluginName, `${pageName}`), 30 `export { default } from "${page.replace(/\.tsx$/, "")}" 31console.info("[Plugin] '${pageName}' from ${pluginName}");`, 32 ); 33 }); 34});
For example, the shim for hello.tsx
in our plugin looks like:
1// generated into server/tmp/plugin/my-nextjs-plugin/pages/hello.tsx 2export { default } from "/Users/evan/workspace/next-plugins/plugins/my-nextjs-plugin/pages/hello"; 3console.info("[Plugin] 'hello.tsx' from my-nextjs-plugin");
This shim does a few things for us:
server
project, Next.js and Webpack will pre-compile and watch this file for uspages/plugins/[plugin]/[page].tsx
to reference our shim rather than the file outside of the project. This keeps webpack much faster.The updated version of pages/plugins/[plugin]/[page].tsx
is now:
1// server/pages/plugins/[plugin]/[page].tsx 2import dynamic from "next/dynamic"; 3import { useRouter } from "next/router"; 4import Link from "next/link"; 5 6export default function PluginContainerPage() { 7 const router = useRouter(); 8 9 // The Next router might not be ready yet... 10 if (!router?.query?.plugin) return null; 11 if (!router?.query?.page) return null; 12 13 // dynamically load the component 14 const PluginComponent = dynamic( 15 () => 16 import( 17 `./../../../tmp/plugin/${router.query.plugin}/${router.query.page}` 18 ), 19 { 20 loading: () => <p>Loading...</p>, 21 }, 22 ); 23 24 return ( 25 <> 26 <Link href="/"> 27 <a>Back</a> 28 </Link> 29 <hr /> 30 <PluginComponent /> 31 </> 32 ); 33}
And you’ll get a nice note in the console too!
You can now include React pages and components from plugins into your Next.js application. The methods outlined here will work for both Next’s development mode (next dev
), and compiled “production” mode with next build && next start
). These techniques will also work for packages you install from NPM, but you’ll need to adjust some of the paths when building your shims. Assuming your NPM packages only contain your not-yet-compiled code (TSX, TS, or JSX files), we will need to make one final adjustment.
By default, the Next.js Webpack plugin does not compile files found within node_modules
, so we’ll need to override that behavior too.
That makes our final next.config.js
:
1// sever/next.config.js 2const glob = require("glob"); 3const path = require("path"); 4const pluginNames = glob 5 .sync(path.join(__dirname, "..", "plugins", "*")) 6 .map((plugin) => plugin.replace(path.join(__dirname, "..", "plugins"), "")) 7 .map((plugin) => plugin.replace(/\//g, "")); 8 9require("./plugins"); // prepare plugins 10 11module.exports = { 12 webpack: (config, options) => { 13 // allow compilation of our plugins when we load them from NPM 14 const rule = config.module.rules[0]; 15 const originalExcludeMethod = rule.exclude; 16 config.module.rules[0].exclude = (moduleName, ...otherArgs) => { 17 // we want to explicitly allow our plugins 18 for (const i in pluginNames) { 19 if (moduleName.indexOf(`node_modules/${pluginNames[i]}`) >= 0) { 20 return false; 21 } 22 } 23 24 // otherwise, use the original rule 25 return originalExcludeMethod(moduleName, ...otherArgs); 26 }; 27 28 // add a rule to compile our plugins from within the monorepo 29 config.module.rules.push({ 30 test: /plugins\/.*\.ts?|plugins\/.*.tsx?/, 31 use: [options.defaultLoaders.babel], 32 }); 33 34 // we want to ensure that the server project's version of react is used in all cases 35 config.resolve.alias["react"] = path.join( 36 __dirname, 37 "node_modules", 38 "react", 39 ); 40 config.resolve.alias["react-dom"] = path.resolve( 41 __dirname, 42 "node_modules", 43 "react-dom", 44 ); 45 46 return config; 47 }, 48};
Note that we’ve also added a config.resolve.alias
section telling Webpack that any time it sees react
or react-dom
, we should always use the version from server
’s package.json. This alias will help you to avoid problems with multiple versions or instances of React."
I write about Technology, Software, and Startups. I use my Product Management, Software Engineering, and Leadership skills to build teams that create world-class digital products.
Get in touch