├── loader.js ├── plugins ├── babel │ ├── index.js │ ├── package.json │ ├── src │ │ ├── babel-preset-build.js │ │ └── plugin.js │ └── README.md ├── next │ ├── package.json │ ├── src │ │ └── document-head-tags-server.js │ └── README.md └── webpack │ ├── FixOutputOptionsPlugin.js │ ├── FixAutoDLLPluginPlugin.js │ ├── NextCriticalPlugin.js │ └── CriticalCompiler.js ├── critical.d.ts ├── jest.config.json ├── critical.js ├── babel.config.js ├── package.json ├── index.js ├── .gitignore └── README.md /loader.js: -------------------------------------------------------------------------------- 1 | module.exports = function () {}; 2 | -------------------------------------------------------------------------------- /plugins/babel/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { dir: __dirname }; 2 | -------------------------------------------------------------------------------- /critical.d.ts: -------------------------------------------------------------------------------- 1 | export default (props: { src: string }) => null; 2 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testEnvironment": "node", 3 | "testRunner": "jest-circus/runner" 4 | } 5 | -------------------------------------------------------------------------------- /critical.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ src }) => { 2 | console.warn('You need to add the next-critical plugin to next'); 3 | return null; 4 | }; 5 | -------------------------------------------------------------------------------- /plugins/babel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-critical-babel-preset-plugin", 3 | "main": "index.js", 4 | "files": [ 5 | "src/babel-preset-build.js" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /plugins/next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-critical-next-plugin", 3 | "nextjs": { 4 | "name": "Next Critical", 5 | "required-env": [] 6 | }, 7 | "files": [ 8 | "src/*.js" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /plugins/babel/src/babel-preset-build.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | 3 | module.exports = function plugin(config) { 4 | const plugin = resolve(__dirname, './plugin.js'); 5 | config.plugins.push([plugin]); 6 | }; 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // 📌 this config is only for jest. 2 | // Unfortunately jest does not bubble up the packages for a root `babel.config.js`. 3 | // See: https://github.com/facebook/jest/issues/7359 4 | module.exports = { 5 | extends: '../../babel.config.js', 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-critical", 3 | "version": "1.0.0", 4 | "author": "Lukas Bombach", 5 | "license": "MIT", 6 | "main": "index.js", 7 | "files": [ 8 | "loader.js", 9 | "critical.js", 10 | "plugins/**/*(.js|.json)" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /plugins/webpack/FixOutputOptionsPlugin.js: -------------------------------------------------------------------------------- 1 | const { dirname } = require('path'); 2 | const NodeOutputFileSystem = require('webpack/lib/node/NodeOutputFileSystem'); 3 | const NodeWatchFileSystem = require('webpack/lib/node/NodeWatchFileSystem'); 4 | 5 | class FixOutputOptionsPlugin { 6 | pluginName = this.constructor.name; 7 | outputFile = undefined; 8 | 9 | constructor({ outputFile }) { 10 | this.outputFile = outputFile; 11 | } 12 | 13 | apply(compiler) { 14 | compiler.outputFileSystem = new NodeOutputFileSystem(); 15 | compiler.watchFileSystem = new NodeWatchFileSystem(); 16 | compiler.outputPath = dirname(this.outputFile); 17 | } 18 | } 19 | 20 | module.exports = FixOutputOptionsPlugin; 21 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const merge = require('deepmerge'); 2 | const NextCriticalPlugin = require('./plugins/webpack/NextCriticalPlugin'); 3 | const BabelPresetPlugin = require('./plugins/babel'); 4 | 5 | module.exports = (nextConfig = {}) => 6 | merge(nextConfig, { 7 | experimental: { 8 | plugins: true, 9 | }, 10 | plugins: ['next-critical/plugins/next'], 11 | webpack: (config, options) => { 12 | config.plugins.push(new NextCriticalPlugin(options.buildId)); 13 | options.defaultLoaders.babel.options.babelPresetPlugins.push(BabelPresetPlugin); 14 | 15 | if (typeof nextConfig.webpack === 'function') { 16 | return nextConfig.webpack(config, options); 17 | } 18 | 19 | return config; 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /plugins/babel/README.md: -------------------------------------------------------------------------------- 1 | # Next Critical Babel Preset Plugin 2 | 3 | This folder needs a short explanation why it is here and why it has a `src` subfolder. 4 | 5 | `Next.js` uses its own `Babel` preset1 and allows adding `BabelPresetPlugin`s via config. 6 | 7 | To add a `BabelPresetPlugin` you need to add an object that in its minimal form contains a `dir` field 8 | pointing to a folder. 9 | 10 | ```json 11 | { 12 | "dir": "/path/to/a/folder" 13 | } 14 | ``` 15 | 16 | that folder _must_ contain a `src` folder with a file called `babel-preset-build.js`. In that file 17 | you may export a function that modifies a config with which you can add a `Babel` plugin. So that is 18 | why there is this folder structure. 19 | 20 | 1 I think 21 | -------------------------------------------------------------------------------- /plugins/webpack/FixAutoDLLPluginPlugin.js: -------------------------------------------------------------------------------- 1 | const { SyncHook } = require('tapable'); 2 | 3 | /** 4 | * Patches a bug where the AutoDLLPlugin is missing this hook 5 | * It will throw an error in this line, saying "cannot call .call of undefined" 6 | * 7 | * https://github.com/asfktz/autodll-webpack-plugin/blob/546ccc450b13e4aad7da95d694e3c0a174b9ccfd/src/plugin.js#L87 8 | * 9 | * because it has not been initialized with this child compiler. So this plugin augments this line+ 10 | * 11 | * https://github.com/asfktz/autodll-webpack-plugin/blob/546ccc450b13e4aad7da95d694e3c0a174b9ccfd/src/plugin.js#L57 12 | * 13 | * todo this may a more thorough investigation & bugifx, but the AutoDLLPlugin will be deprecated with Webpack 5 anyway 14 | **/ 15 | class FixOutputOptionsPlugin { 16 | pluginName = this.constructor.name; 17 | 18 | apply(compiler) { 19 | compiler.hooks.autodllStatsRetrieved = new SyncHook(['stats', 'source']); 20 | } 21 | } 22 | 23 | module.exports = FixOutputOptionsPlugin; 24 | -------------------------------------------------------------------------------- /plugins/next/src/document-head-tags-server.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import React from 'react'; 4 | 5 | export default async function headTags() { 6 | const __html = await getCriticalJS(); 7 | return ; 8 | } 9 | 10 | async function getCriticalJS() { 11 | const buildId = await getBuildId(); 12 | const criticalJsFile = path.resolve(process.cwd(), '.next/server/static', buildId, 'pages/_critical.js'); 13 | return await fs.promises.readFile(criticalJsFile, 'utf-8'); 14 | } 15 | 16 | async function getBuildId() { 17 | if (process.env.NODE_ENV === 'production') { 18 | return await readBuildId(); 19 | } 20 | if (process.env.NODE_ENV === 'development') { 21 | return 'development'; 22 | } 23 | throw new Error(`Cannot handle env ${env}`); 24 | } 25 | 26 | async function readBuildId() { 27 | const buildIdPath = path.resolve(process.cwd(), '.next/BUILD_ID'); 28 | return await fs.promises.readFile(buildIdPath, 'utf-8'); 29 | } 30 | -------------------------------------------------------------------------------- /plugins/next/README.md: -------------------------------------------------------------------------------- 1 | # Next Critical Next Plugin 2 | 3 | This folder needs a short explanation why it is here and why it has a `src` subfolder. 4 | 5 | `Next.js` has a experimental plugin API that works based on a `package.json` and the 6 | structure of the file system. 7 | 8 | In the `next.config.js` you can add these fields to add a plugin: 9 | 10 | ```json 11 | { 12 | "experimental": { 13 | "plugins": true 14 | }, 15 | "plugins": ["next-critical/plugins/next"] 16 | } 17 | ``` 18 | 19 | 1. Enable plugins via `experimental.plugins = true` 20 | 1. Add plugins via `plugins: ["array", "of", "plugins"]` 21 | 22 | The array allows any path that can be resolved via node's `resolve`. 23 | 24 | To implement a plugin you need 2 things: 25 | 26 | 1. A package.json containing at least these fields: 27 | ```json 28 | { 29 | "name": "Your Package Name", 30 | "nextjs": { 31 | "name": "Your Plugin Name", 32 | "required-env": [] 33 | } 34 | } 35 | ``` 36 | 1. A `src` folder with _specifically named files_. `Next` will look in the `src` folder 37 | for specific file names and if you provide these files, depending on the file name, 38 | those files will be used in `Next` in specific ways 39 | 1. The file `src/document-head-tags-server.js` can export a function and whatever that 40 | function returns will be included in the `
` section on the server side. 41 | -------------------------------------------------------------------------------- /plugins/webpack/NextCriticalPlugin.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const CriticalCompiler = require('./CriticalCompiler'); 3 | 4 | class NextCriticalWebpackPlugin { 5 | pluginName = this.constructor.name; 6 | dependencies = []; 7 | outputFile = undefined; 8 | 9 | constructor(buildId) { 10 | buildId = process.env.NODE_ENV === 'development' ? 'development' : buildId; 11 | const criticalFile = `.next/server/static/${buildId}/pages/_critical.js`; 12 | this.outputFile = resolve(process.cwd(), criticalFile); 13 | } 14 | 15 | apply(compiler) { 16 | this.extractCriticalDependencies(compiler); 17 | this.compileCriticalJs(compiler); 18 | } 19 | 20 | extractCriticalDependencies(compiler) { 21 | compiler.hooks.normalModuleFactory.tap(this.pluginName, factory => { 22 | factory.hooks.beforeResolve.tap(this.pluginName, result => this.checkExtract(result)); 23 | }); 24 | } 25 | 26 | compileCriticalJs(compiler) { 27 | compiler.hooks.afterCompile.tapPromise(this.pluginName, async compilation => { 28 | if (compilation.name !== 'client') return; 29 | const { dependencies, outputFile } = this; 30 | const criticalCompiler = new CriticalCompiler(compilation, { dependencies, outputFile }); 31 | await criticalCompiler.run(); 32 | }); 33 | } 34 | 35 | checkExtract(result) { 36 | if (result && result.request && result.request.startsWith('next-critical/loader')) { 37 | this.extractDependency(result); 38 | return null; 39 | } 40 | return result; 41 | } 42 | 43 | extractDependency(result) { 44 | const [criticalDependency] = result.dependencies; 45 | const { originModule } = criticalDependency; 46 | const index = originModule.dependencies.indexOf(criticalDependency); 47 | originModule.dependencies.splice(index, 1); 48 | this.dependencies.push(criticalDependency); 49 | } 50 | } 51 | 52 | module.exports = NextCriticalWebpackPlugin; 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /plugins/babel/src/plugin.js: -------------------------------------------------------------------------------- 1 | const { dirname, resolve } = require('path'); 2 | const pkg = require('../../../package.json'); 3 | 4 | module.exports = ({ types: t }) => { 5 | return { 6 | visitor: { 7 | Program: { 8 | enter(path, state) { 9 | const dir = dirname(state.filename); 10 | const imports = findImports(path); 11 | if (!imports.length) return; 12 | const criticalComponents = getLocalNames(imports); 13 | const jsxElements = findCriticalJSXElements(path, criticalComponents); 14 | const sources = getSources(jsxElements).map(source => resolve(dir, source)); 15 | const newImports = sources 16 | .map(source => `next-critical/loader!${source}`) 17 | .map(source => t.importDeclaration([], t.stringLiteral(source))); 18 | newImports.forEach(declaration => path.unshiftContainer('body', declaration)); 19 | imports.forEach(path => path.remove()); 20 | jsxElements.forEach(path => path.remove()); 21 | }, 22 | }, 23 | }, 24 | }; 25 | }; 26 | 27 | function findImports(path) { 28 | const paths = []; 29 | const enter = path => { 30 | if (!path.node || !path.node.source) return; 31 | if (path.node.source.value === `${pkg.name}/critical`) paths.push(path); 32 | }; 33 | path.traverse({ ImportDeclaration: { enter } }); 34 | return paths; 35 | } 36 | 37 | function findCriticalJSXElements(path, componentNames) { 38 | const paths = []; 39 | const componentNamesMatcher = new RegExp(`(${componentNames.join('|')})`); 40 | const enter = path => { 41 | if (componentNamesMatcher.test(path.node.openingElement.name.name)) paths.push(path); 42 | }; 43 | path.traverse({ JSXElement: { enter } }); 44 | return paths; 45 | } 46 | 47 | function getLocalNames(imports) { 48 | return imports.map(path => path.node.specifiers[0].local.name); 49 | } 50 | 51 | function getSources(jsxElements) { 52 | return jsxElements 53 | .map(path => path.node.openingElement.attributes.find(attr => attr.name.name === 'src')) 54 | .filter(attr => !!attr && !!attr.value) 55 | .map(attr => attr.value.value) 56 | .filter(source => typeof source !== 'undefined'); 57 | } 58 | -------------------------------------------------------------------------------- /plugins/webpack/CriticalCompiler.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { promisify } = require('util'); 3 | const { basename } = require('path'); 4 | const tmp = require('tmp'); 5 | const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin'); 6 | const FixOutputOptionsPlugin = require('./FixOutputOptionsPlugin'); 7 | const FixAutoDLLPluginPlugin = require('./FixAutoDLLPluginPlugin'); 8 | 9 | class CriticalCompiler { 10 | compilationName = 'next-critical'; 11 | compilation = undefined; 12 | options = undefined; 13 | 14 | constructor(compilation, options) { 15 | this.compilation = compilation; 16 | this.options = options; 17 | } 18 | 19 | async run() { 20 | if (this.compilation.name === this.compilationName) return; 21 | const compiler = await this.getCompiler(); 22 | const stats = await promisify(compiler.run.bind(compiler))(); 23 | this.handleCompilationErrors(stats); 24 | } 25 | 26 | async getCompiler() { 27 | const plugins = await this.getPlugins(); 28 | const output = { path: '/', filename: basename(this.options.outputFile) }; 29 | const compiler = this.compilation.createChildCompiler(this.compilationName, output); 30 | plugins.forEach(plugin => plugin.apply(compiler)); 31 | return compiler; 32 | } 33 | 34 | async getPlugins() { 35 | const entry = await this.createEntryFile(); 36 | const { context } = this.compilation.options; 37 | const { outputFile } = this.options; 38 | return [ 39 | new SingleEntryPlugin(context, entry, this.pluginName), 40 | new FixOutputOptionsPlugin({ outputFile }), 41 | new FixAutoDLLPluginPlugin(), 42 | ]; 43 | } 44 | 45 | async createEntryFile() { 46 | const source = this.getEntryFileSource(); 47 | const path = await promisify(tmp.file.bind(tmp))({ postfix: '.ts' }); 48 | await fs.promises.writeFile(path, source, 'utf-8'); 49 | return path; 50 | } 51 | 52 | getEntryFileSource() { 53 | return this.options.dependencies 54 | .map(({ request }) => request.replace(/^next-critical\/loader!/, '')) 55 | .map(r => `import "${r}";`) 56 | .join('\n'); 57 | } 58 | 59 | handleCompilationErrors(stats) { 60 | const info = stats.toJson(); 61 | info.warnings.forEach(warning => console.warn(warning)); 62 | if (stats.hasErrors()) throw new Error(info.errors[0]); 63 | } 64 | } 65 | 66 | module.exports = CriticalCompiler; 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next Critical 2 | 3 | This repository aims to find a solution to enable critical JS and critical CSS in Next.js. 4 | 5 | ## Usage 6 | 7 | Add to your Next project 8 | 9 | ```bash 10 | yarn add https://github.com/stroeer/next-critical 11 | ``` 12 | 13 | Add to your `next.config.js` 14 | 15 | ```js 16 | const withCritical = require("next-critical"); 17 | 18 | module.exports = withCritical(); 19 | ``` 20 | 21 | In your app, import `Critical` from `'next-critical/critical'` and refererence any code you would 22 | like to use as critical JS / CSS 23 | 24 | ```tsx 25 | import React from "react"; 26 | import Critical from "next-critical/critical"; 27 | 28 | const MyPage = () => ( 29 |` of the SSR-rendered document.
57 |
58 | Most of this is still experimental but we did not experience any problems yet.
59 |
60 | ### API considerations
61 |
62 | We are still struggling with the design of the API. Using a ` etc
` section 99 | - `withCritical` would be an implementation of an HOC that extracts JS and CSS from its wrapped component 100 | 101 | each solution has its use case and it's hard for us to tell if there is a single solution that solves all problems. 102 | 103 | ## Status 104 | 105 | I would not use this in production yet but I do think this approach is feasable. I think we 106 | should try this out while we are not live yet and make this stable as we go. I will try and 107 | contact the Next.js guys, do more research on Webpack and maybe find people who know Webpack 108 | to get their advise on what we are doing here. 109 | 110 | Todo: 111 | 112 | - [ ] Add functionality for Critical CSS 113 | - [ ] TypeScript everything 114 | - [ ] The plugin that removes the webpack runtime does not work in dev mode yet and needs some review 115 | - [ ] Remove Terser from the plugin that removes the webpack runtime 116 | - [ ] Get rid of fractured, hard-coded paths and strings 117 | - [ ] Make sure we are not overwriting stuff with the generated `_critical.js` 118 | - [ ] Make sure what we are doing with Webpack and Next is an ok thing to do 119 | - [ ] Handle errors and Edge-Cases 120 | - [ ] Unit Tests ❗️ 121 | --------------------------------------------------------------------------------