├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── packages ├── example │ ├── .eslintrc │ ├── .gitignore │ ├── README.md │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── src │ │ ├── components │ │ │ ├── Button │ │ │ │ ├── Button.module.scss │ │ │ │ ├── Button.tsx │ │ │ │ └── index.tsx │ │ │ ├── Counter │ │ │ │ ├── Counter.client.tsx │ │ │ │ ├── Counter.module.scss │ │ │ │ ├── Counter.tsx │ │ │ │ └── index.tsx │ │ │ ├── Image │ │ │ │ ├── CssImage.module.scss │ │ │ │ ├── CssImage.tsx │ │ │ │ ├── Image.tsx │ │ │ │ ├── image.svg │ │ │ │ └── index.ts │ │ │ ├── Layout │ │ │ │ ├── Layout.module.scss │ │ │ │ ├── Layout.tsx │ │ │ │ └── index.tsx │ │ │ ├── Navigation │ │ │ │ ├── Navigation.module.scss │ │ │ │ ├── Navigation.tsx │ │ │ │ └── index.tsx │ │ │ ├── ReactComponent │ │ │ │ ├── ReactComponent.tsx │ │ │ │ └── index.tsx │ │ │ ├── Text │ │ │ │ ├── Text.module.scss │ │ │ │ ├── Text.tsx │ │ │ │ └── index.tsx │ │ │ └── Toggler │ │ │ │ ├── Toggler.client.tsx │ │ │ │ ├── Toggler.module.scss │ │ │ │ ├── Toggler.tsx │ │ │ │ └── index.tsx │ │ └── pages │ │ │ ├── _document.page.tsx │ │ │ ├── dynamic │ │ │ ├── [initialCount].client.tsx │ │ │ └── [initialCount].page.tsx │ │ │ ├── index.client.tsx │ │ │ ├── index.page.tsx │ │ │ └── tests │ │ │ ├── data.page.tsx │ │ │ ├── index.client.tsx │ │ │ ├── index.page.tsx │ │ │ ├── nested.client.tsx │ │ │ ├── nested.page.tsx │ │ │ └── react.page.tsx │ ├── tsconfig.json │ ├── types │ │ └── assets.d.ts │ └── yarn.lock └── next-client-script │ ├── .eslintrc │ ├── package.json │ ├── rollup.config.js │ ├── src │ ├── ClientScript.tsx │ ├── ClientScriptsByPath.tsx │ ├── ClientWidget.tsx │ ├── initWidgets.tsx │ └── withClientScripts.tsx │ ├── tsconfig.json │ └── types │ └── next-transpile-modules.d.ts └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-node@v1 9 | with: 10 | node-version: 12.x 11 | - uses: actions/cache@v1 12 | with: 13 | path: node_modules 14 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 15 | restore-keys: | 16 | nodeModules- 17 | - run: yarn install --frozen-lockfile 18 | env: 19 | CI: true 20 | - run: cd packages/next-client-script && yarn build 21 | - run: yarn lint 22 | env: 23 | CI: true 24 | - run: cd packages/example && yarn build 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | .next -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.0 4 | 5 | Compatibility with `next@^9.5.0`. Older versions are no longer supported. 6 | 7 | ## 0.0.6 8 | 9 | First working version that's compatible with `next@^9.4.0`. 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jan Amann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > This project is in maintainence mode now, check out [React Server Components](https://beta.nextjs.org/docs/rendering/server-and-client-components#server-components) instead! 3 | 4 | # next-client-script 5 | 6 | > Supercharge the performance of your Next.js apps by using a minimal client runtime that avoids full-blown hydration. 🚀 7 | 8 | ## The problem 9 | 10 | By default, Next.js adds the code to your client bundle that is necessary to execute your whole page. At a minimum this includes React itself, the components to render the markup and if relevant, the data that is necessary to rehydrate the markup (result from `getInitialProps` and friends). 11 | 12 | For content heavy sites this [can cause performance issues](https://developers.google.com/web/updates/2019/02/rendering-on-the-web#rehydration) since the page is unresponsive while the client bundle is being executed. 13 | 14 | Recently, an [early version of removing the client side bundle](https://github.com/vercel/next.js/pull/11949) was shipped to Next.js which doesn't suffer from performance problems caused by hydration. However, for a typical website you'll likely still need some JavaScript on the client side to deliver a reasonable user experience. 15 | 16 | ## This solution 17 | 18 | This is a Next.js plugin that is intended to be used in conjunction with disabled runtime JavaScript. You can add client bundles on a per-page basis that only sprinkle a tiny bit of JavaScript over otherwise completely static pages. 19 | 20 | This allows for the same [architecture that Netflix has chosen for their public pages](https://medium.com/dev-channel/a-netflix-web-performance-case-study-c0bcde26a9d9). 21 | 22 | **Benefits:** 23 | 24 | - Keep the React component model for rendering your markup server side 25 | - Use the Next.js development experience and build pipeline for optimizing the server response 26 | - A client side runtime for components is opt-in 27 | - Serializing data for the client is opt-in 28 | 29 | The tradeoff is that you can't use any client-side features of React (state, effects, event handlers, …). Note that some features of Next.js might not be available (yet) – e.g. code splitting via `dynamic` within a page. 30 | 31 | → [Demo deployment](https://next-client-script.vercel.app/) ([source](https://github.com/amannn/next-client-script/tree/master/packages/example)) 32 | 33 | 34 | ## Compatibility 35 | 36 | ⚠️ **Important:** To achieve the desired effect, this plugin modifies the webpack configuration that Next.js consumes. Similar as with other Next.js plugins, it's possible that this plugin will break when there are updates to Next.js. I'm keeping the plugin updated so that it continues to work with new versions of Next.js. 37 | 38 | | Next.js version | Plugin version | 39 | | ------------- | ------------- | 40 | | ^10.0.0, ^9.5.0 | 0.1.0 | 41 | | ~9.4.0 | 0.0.6 | 42 | 43 | Latest version tested: 10.0.5 44 | 45 | ## Getting started 46 | 47 | ### Minimum setup 48 | 49 | 1. Add a client script for a page. 50 | 51 | ```js 52 | // ./src/client/index.ts 53 | console.log('Hello from client.'); 54 | ``` 55 | 56 | 2. Add this plugin to your `next.config.js` and reference your client script. 57 | 58 | ```js 59 | const withClientScripts = require('next-client-script/withClientScripts'); 60 | 61 | // Define which paths will cause which scripts to load 62 | module.exports = withClientScripts({ 63 | '/': './src/client/index.ts', 64 | // You can use parameters as provided by path-to-regexp to match routes dynamically. 65 | '/products/:id': './src/client/product.ts' 66 | })(); 67 | ``` 68 | 69 | 3. Add a [custom document to your app](https://nextjs.org/docs/advanced-features/custom-document) and add the `` component as the last child in the body. 70 | 71 | ```diff 72 | + import ClientScript from 'next-client-script/ClientScript'; 73 | 74 | // ... 75 | 76 | + 77 | 78 | ``` 79 | 80 | 4. **Recommended**: Disable the runtime JavaScript for the pages with separate client scripts: 81 | 82 | ```js 83 | // ./pages/index.ts 84 | export const config = { 85 | unstable_runtimeJS: false 86 | }; 87 | ``` 88 | 89 | Note that you can mix this approach with the traditional hydration approach, to optimize the performance of critical pages while keeping the flexibility of using React on the client side for other pages. 90 | 91 | See [the example folder](https://github.com/amannn/next-client-script/blob/master/packages/example) for a project demonstrating this setup. 92 | 93 | ### Widget usage 94 | 95 | To help with a component-oriented approach for client-side code, this library contains convenience APIs that help with passing data to the client and initializing widgets. 96 | 97 | Use the `ClientWidget` component to mark an entry point for the client side and to optionally pass in some data. 98 | 99 | ```js 100 | // Counter.js 101 | import ClientWidget from 'next-client-script/ClientWidget'; 102 | import styles from './Counter.module.scss'; 103 | 104 | export default function Counter({initialCount = 2}) { 105 | return ( 106 | 107 |

108 | Count: {initialCount} 109 |

110 | 111 |
112 | ); 113 | } 114 | ``` 115 | 116 | Now you can add a client part for this component that receives the data and adds interactivity. 117 | 118 | ```js 119 | // Counter.client.js 120 | import styles from './Counter.module.scss'; 121 | 122 | export default function initCounter(rootNode, data) { 123 | let count = data.initialCount; 124 | 125 | const countNode = rootNode.querySelector(`.${styles.count}`); 126 | const buttonNode = rootNode.querySelector(`.${styles.button}`); 127 | 128 | buttonNode.addEventListener('click', () => { 129 | count++; 130 | countNode.textContent = count; 131 | }); 132 | } 133 | 134 | // This will be passed to `querySelectorAll` to find all widgets on the page 135 | initCounter.selector = `.${styles.root}`; 136 | ``` 137 | 138 | As a last step, you need to reference the client counter code in your client script: 139 | 140 | ```js 141 | import initWidgets from 'next-client-script/initWidgets'; 142 | import Counter from 'components/Counter/Counter.client'; 143 | 144 | initWidgets([Counter]); 145 | ``` 146 | 147 | ## Prior art & credits 148 | 149 | - [A Netflix Web Performance Case Study](https://medium.com/dev-channel/a-netflix-web-performance-case-study-c0bcde26a9d9) by [Addy Osmani](https://twitter.com/addyosmani) 150 | - [next-critical](https://github.com/stroeer/next-critical) by [Lukas Bombach](https://github.com/stroeer/next-critical) 151 | 152 | I really hope that React will solve hydration problems in the future with [partial hydration](https://github.com/facebook/react/pull/14717) and [server-side components](https://github.com/facebook/react/tree/master/fixtures/blocks), but I think a tiny bit of vanilla JavaScript on the client side is really hard to beat. 153 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "lint": "cd packages/next-client-script && yarn lint && cd ../example && yarn lint" 5 | }, 6 | "workspaces": [ 7 | "packages/example", 8 | "packages/next-client-script" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /packages/example/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["molindo/typescript", "molindo/react"], 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "rules": { 8 | "react/react-in-jsx-scope": "OFF" 9 | }, 10 | "overrides": [ 11 | { 12 | "files": ["*.client.tsx"], 13 | "rules": { 14 | "css-modules/no-unused-class": "OFF" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /packages/example/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel -------------------------------------------------------------------------------- /packages/example/README.md: -------------------------------------------------------------------------------- 1 | # next-client-script example 2 | 3 | ## Test plan 4 | 5 | - Development 6 | - Script loads and executes 7 | - Reloads the page on change 8 | - Production 9 | - Script loads and executes 10 | - Script has immutable caching header 11 | -------------------------------------------------------------------------------- /packages/example/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/example/next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const withClientScripts = require('next-client-script/dist/withClientScripts'); 3 | const withImages = require('next-images'); 4 | 5 | const nextConfig = { 6 | pageExtensions: ['page.tsx'] 7 | }; 8 | 9 | module.exports = withImages( 10 | withClientScripts({ 11 | '/': './src/pages/index.client.tsx', 12 | '/tests': './src/pages/tests/index.client.tsx', 13 | '/tests/nested': './src/pages/tests/nested.client.tsx', 14 | '/dynamic/:initialCount': './src/pages/dynamic/[initialCount].client.tsx' 15 | })(nextConfig) 16 | ); 17 | -------------------------------------------------------------------------------- /packages/example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "rm -rf .next && next build", 8 | "lint": "eslint \"src/**/*.{ts,tsx}\" && tsc", 9 | "start": "next start", 10 | "deploy": "vercel" 11 | }, 12 | "dependencies": { 13 | "next": "10.0.5", 14 | "next-client-script": "0.1.0", 15 | "react": "16.13.1", 16 | "react-dom": "16.13.1" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "14.0.14", 20 | "@types/react": "16.9.41", 21 | "eslint": "7.4.0", 22 | "eslint-config-molindo": "5.0.0-alpha.10", 23 | "eslint-plugin-filenames": "1.3.2", 24 | "next-images": "1.6.2", 25 | "sass": "1.26.9", 26 | "typescript": "3.9.6" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amannn/next-client-script/1e30eeeced9bdd874eeb2f77d66e990026cf23af/packages/example/public/favicon.ico -------------------------------------------------------------------------------- /packages/example/src/components/Button/Button.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | background-color: #4572e4; 3 | color: white; 4 | padding: 10px 20px; 5 | border: none; 6 | border-radius: 5px; 7 | font-size: 14px; 8 | } 9 | -------------------------------------------------------------------------------- /packages/example/src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import {DetailedHTMLProps, ButtonHTMLAttributes} from 'react'; 2 | import styles from './Button.module.scss'; 3 | 4 | export default function Button({ 5 | className, 6 | ...rest 7 | }: DetailedHTMLProps< 8 | ButtonHTMLAttributes, 9 | HTMLButtonElement 10 | >) { 11 | return ( 12 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /packages/example/src/components/Counter/index.tsx: -------------------------------------------------------------------------------- 1 | export {default} from './Counter'; 2 | -------------------------------------------------------------------------------- /packages/example/src/components/Image/CssImage.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | width: 25px; 3 | height: 25px; 4 | background: url('./image.svg'), #ddd; 5 | } 6 | -------------------------------------------------------------------------------- /packages/example/src/components/Image/CssImage.tsx: -------------------------------------------------------------------------------- 1 | import styles from './CssImage.module.scss'; 2 | 3 | export default function CssImage() { 4 | return
; 5 | } 6 | -------------------------------------------------------------------------------- /packages/example/src/components/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import image from './image.svg'; 2 | 3 | export default function Image() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /packages/example/src/components/Image/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/example/src/components/Image/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Image'; 2 | export {default as CssImage} from './CssImage'; 3 | -------------------------------------------------------------------------------- /packages/example/src/components/Layout/Layout.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | padding: 20px; 3 | font-family: Avenir, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, 4 | Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 5 | } 6 | 7 | .children { 8 | margin-top: 40px; 9 | } 10 | -------------------------------------------------------------------------------- /packages/example/src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import {ReactNode} from 'react'; 3 | import Navigation from 'components/Navigation'; 4 | import styles from './Layout.module.scss'; 5 | 6 | type Props = { 7 | children: ReactNode; 8 | }; 9 | 10 | export default function Layout({children}: Props) { 11 | return ( 12 | <> 13 | 14 | next-client-script 15 | 16 | 17 |
18 | 19 |
{children}
20 |
21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/example/src/components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | export {default} from './Layout'; 2 | -------------------------------------------------------------------------------- /packages/example/src/components/Navigation/Navigation.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | padding: 20px 30px; 4 | background-color: #f0f0f0; 5 | border-radius: 8px; 6 | } 7 | 8 | .internal { 9 | margin-right: auto; 10 | 11 | > *:not(:first-child) { 12 | margin-left: 20px; 13 | } 14 | } 15 | 16 | .link { 17 | color: inherit; 18 | text-decoration: none; 19 | } 20 | -------------------------------------------------------------------------------- /packages/example/src/components/Navigation/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Navigation.module.scss'; 2 | 3 | export default function Navigation() { 4 | // Since we're expecting full page reloads, we don't 5 | // need to use the link component from Next.js 6 | return ( 7 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/example/src/components/Navigation/index.tsx: -------------------------------------------------------------------------------- 1 | export {default} from './Navigation'; 2 | -------------------------------------------------------------------------------- /packages/example/src/components/ReactComponent/ReactComponent.tsx: -------------------------------------------------------------------------------- 1 | import Button from 'components/Button'; 2 | 3 | export default function ReactComponent() { 4 | function onClick() { 5 | alert('This was triggered by React from a hydrated client.'); 6 | } 7 | 8 | return ( 9 |
10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/example/src/components/ReactComponent/index.tsx: -------------------------------------------------------------------------------- 1 | export {default} from './ReactComponent'; 2 | -------------------------------------------------------------------------------- /packages/example/src/components/Text/Text.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | &_title { 3 | font-size: 32px; 4 | } 5 | 6 | &_body { 7 | font-size: 16px; 8 | opacity: 0.8; 9 | line-height: 1.5; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/example/src/components/Text/Text.tsx: -------------------------------------------------------------------------------- 1 | import React, {ReactNode, ElementType} from 'react'; 2 | import styles from './Text.module.scss'; 3 | 4 | type Props = { 5 | as?: ElementType; 6 | children: ReactNode; 7 | variant?: 'title' | 'body'; 8 | }; 9 | 10 | export default function Text({ 11 | as: Component = 'p', 12 | children, 13 | variant = 'body' 14 | }: Props): JSX.Element { 15 | return ( 16 | 24 | {children} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/example/src/components/Text/index.tsx: -------------------------------------------------------------------------------- 1 | export {default} from './Text'; 2 | -------------------------------------------------------------------------------- /packages/example/src/components/Toggler/Toggler.client.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Toggler.module.scss'; 2 | 3 | export default function initCounter(rootNode: HTMLElement) { 4 | let isToggled = false; 5 | 6 | const buttonNode = rootNode.querySelector(`.${styles.button}`); 7 | if (!buttonNode) return; 8 | 9 | buttonNode.addEventListener('click', () => { 10 | isToggled = !isToggled; 11 | render(); 12 | }); 13 | 14 | function render() { 15 | rootNode.classList.toggle(styles.root_toggled, isToggled); 16 | } 17 | } 18 | 19 | initCounter.selector = `.${styles.root}`; 20 | -------------------------------------------------------------------------------- /packages/example/src/components/Toggler/Toggler.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | background: #eee; 3 | padding: 10px; 4 | 5 | &_toggled { 6 | background: #ddd; 7 | } 8 | } 9 | 10 | .button { 11 | display: block; 12 | } 13 | -------------------------------------------------------------------------------- /packages/example/src/components/Toggler/Toggler.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable css-modules/no-unused-class */ 2 | import ClientWidget from 'next-client-script/dist/ClientWidget'; 3 | import Button from 'components/Button'; 4 | import styles from './Toggler.module.scss'; 5 | 6 | // This example tests that the whole stylesheet is loaded and individual 7 | // classes aren't dead code eliminated. This is relevant, if classes are 8 | // only later added from the client side. 9 | 10 | export default function Toggler() { 11 | return ( 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/example/src/components/Toggler/index.tsx: -------------------------------------------------------------------------------- 1 | export {default} from './Toggler'; 2 | -------------------------------------------------------------------------------- /packages/example/src/pages/_document.page.tsx: -------------------------------------------------------------------------------- 1 | import ClientScript from 'next-client-script/dist/ClientScript'; 2 | import Document, {Html, Head, Main, NextScript} from 'next/document'; 3 | 4 | export default class CustomDocument extends Document { 5 | public render() { 6 | return ( 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/example/src/pages/dynamic/[initialCount].client.tsx: -------------------------------------------------------------------------------- 1 | import initWidgets from 'next-client-script/dist/initWidgets'; 2 | import Counter from 'components/Counter/Counter.client'; 3 | 4 | initWidgets([Counter]); 5 | -------------------------------------------------------------------------------- /packages/example/src/pages/dynamic/[initialCount].page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PageConfig, 3 | GetServerSidePropsContext, 4 | GetServerSidePropsResult 5 | } from 'next'; 6 | import Counter from 'components/Counter'; 7 | import Layout from 'components/Layout'; 8 | import Text from 'components/Text'; 9 | 10 | export const config: PageConfig = { 11 | unstable_runtimeJS: false 12 | }; 13 | 14 | type Props = { 15 | initialCount: number; 16 | }; 17 | 18 | export function getServerSideProps( 19 | context: GetServerSidePropsContext 20 | ): GetServerSidePropsResult { 21 | if (typeof context.params?.initialCount !== 'string') { 22 | throw new Error(`Invalid initialCount: ${context.params?.initialCount}`); 23 | } 24 | 25 | return {props: {initialCount: parseInt(context.params.initialCount)}}; 26 | } 27 | 28 | export default function Dynamic({initialCount}: Props) { 29 | return ( 30 | 31 | 32 | Dynamic matching with path-to-regexp 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /packages/example/src/pages/index.client.tsx: -------------------------------------------------------------------------------- 1 | import initWidgets from 'next-client-script/dist/initWidgets'; 2 | import Counter from 'components/Counter/Counter.client'; 3 | 4 | initWidgets([Counter]); 5 | -------------------------------------------------------------------------------- /packages/example/src/pages/index.page.tsx: -------------------------------------------------------------------------------- 1 | import {PageConfig} from 'next'; 2 | import Counter from 'components/Counter'; 3 | import Layout from 'components/Layout'; 4 | import Text from 'components/Text'; 5 | 6 | export const config: PageConfig = { 7 | unstable_runtimeJS: false 8 | }; 9 | 10 | export default function Home() { 11 | return ( 12 | 13 | 14 | next-client-script 15 | 16 | 17 | This Next.js app uses a minimal runtime to make this counter interactive 18 | and avoids full-blown hydration. 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/example/src/pages/tests/data.page.tsx: -------------------------------------------------------------------------------- 1 | import {GetServerSidePropsResult, PageConfig} from 'next'; 2 | import Layout from 'components/Layout'; 3 | import Text from 'components/Text'; 4 | 5 | export const config: PageConfig = { 6 | unstable_runtimeJS: false 7 | }; 8 | 9 | type Props = { 10 | time: number; 11 | }; 12 | 13 | export function getServerSideProps(): GetServerSidePropsResult { 14 | return { 15 | props: { 16 | time: Date.now() 17 | } 18 | }; 19 | } 20 | 21 | export default function Data({time}: Props) { 22 | return ( 23 | 24 | 25 | Dynamic data from server 26 | 27 | Server time: {new Date(time).toISOString()} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/example/src/pages/tests/index.client.tsx: -------------------------------------------------------------------------------- 1 | import initWidgets from 'next-client-script/dist/initWidgets'; 2 | import Toggler from 'components/Toggler/Toggler.client'; 3 | 4 | initWidgets([Toggler]); 5 | -------------------------------------------------------------------------------- /packages/example/src/pages/tests/index.page.tsx: -------------------------------------------------------------------------------- 1 | import {PageConfig} from 'next'; 2 | import Image, {CssImage} from 'components/Image'; 3 | import Layout from 'components/Layout'; 4 | import Text from 'components/Text'; 5 | import Toggler from 'components/Toggler'; 6 | 7 | export const config: PageConfig = { 8 | unstable_runtimeJS: false 9 | }; 10 | 11 | export default function Tests() { 12 | return ( 13 | 14 | 15 | Tests 16 | 17 |
18 | 19 | 20 |
21 |
22 | 23 |
24 |
25 | 26 | Nested link 27 | 28 | 29 | Data 30 | 31 | 32 | React with hydration 33 | 34 | 35 | Dynamic route 36 | 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /packages/example/src/pages/tests/nested.client.tsx: -------------------------------------------------------------------------------- 1 | import initWidgets from 'next-client-script/dist/initWidgets'; 2 | import Counter from 'components/Counter/Counter.client'; 3 | 4 | initWidgets([Counter]); 5 | -------------------------------------------------------------------------------- /packages/example/src/pages/tests/nested.page.tsx: -------------------------------------------------------------------------------- 1 | import {PageConfig} from 'next'; 2 | import Counter from 'components/Counter'; 3 | import Layout from 'components/Layout'; 4 | import Text from 'components/Text'; 5 | 6 | export const config: PageConfig = { 7 | unstable_runtimeJS: false 8 | }; 9 | 10 | export default function Nested() { 11 | return ( 12 | 13 | 14 | Nested page 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/example/src/pages/tests/react.page.tsx: -------------------------------------------------------------------------------- 1 | import Layout from 'components/Layout'; 2 | import ReactComponent from 'components/ReactComponent'; 3 | import Text from 'components/Text'; 4 | 5 | export default function ReactPage() { 6 | return ( 7 | 8 | 9 | React on the client side 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-molindo/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "baseUrl": "src", 6 | "esModuleInterop": true, 7 | "isolatedModules": true, 8 | "jsx": "preserve", 9 | "lib": ["dom", "dom.iterable", "esnext"], 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "noEmit": true, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "target": "es5" 16 | }, 17 | "exclude": [ 18 | "node_modules" 19 | ], 20 | "include": [ 21 | "next-env.d.ts", 22 | "next.config.js", 23 | "**/*.ts", 24 | "**/*.tsx" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/example/types/assets.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /packages/next-client-script/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["molindo/react", "molindo/typescript"], 3 | "env": { 4 | "node": true 5 | }, 6 | "globals": { 7 | "CLIENT_SCRIPTS_BY_PATH": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/next-client-script/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-client-script", 3 | "version": "0.1.0", 4 | "description": "Add a separate client entry point to your Next.js pages.", 5 | "repository": "https://github.com/amannn/next-client-script", 6 | "author": "Jan Amann ", 7 | "license": "MIT", 8 | "files": [ 9 | "ClientScript.*", 10 | "ClientScriptsByPath.*", 11 | "ClientWidget.*", 12 | "initWidgets.*", 13 | "withClientScripts.*", 14 | "tslib.*" 15 | ], 16 | "engines": { 17 | "node": ">=10" 18 | }, 19 | "scripts": { 20 | "dev": "rm -rf ./dist && rollup -c rollup.config.js --watch", 21 | "build": "rm -rf ./dist && rollup -c rollup.config.js", 22 | "lint": "eslint \"src/**/*.{ts,tsx}\" && tsc --noEmit", 23 | "prepublishOnly": "yarn lint && yarn build && cp dist/* . && cp ../../README.md . && cp ../../CHANGELOG.md ." 24 | }, 25 | "dependencies": { 26 | "chalk": "^4.0.0", 27 | "next-transpile-modules": "^4.1.0", 28 | "path-to-regexp": "6.1.0", 29 | "webpack": "^4.44.1" 30 | }, 31 | "peerDependencies": { 32 | "next": "^9.5.0", 33 | "react": "^16.8.0" 34 | }, 35 | "devDependencies": { 36 | "@rollup/plugin-commonjs": "13.0.0", 37 | "@rollup/plugin-typescript": "5.0.1", 38 | "@types/mini-css-extract-plugin": "0.9.1", 39 | "@types/next": "9.0.0", 40 | "@types/react": "16.9.41", 41 | "@types/react-dom": "16.9.8", 42 | "@types/webpack": "4.41.18", 43 | "eslint": "7.4.0", 44 | "eslint-config-molindo": "5.0.0-alpha.10", 45 | "rollup": "2.18.2", 46 | "tslib": "2.0.0", 47 | "typescript": "3.9.6" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/next-client-script/rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import typescript from '@rollup/plugin-typescript'; 4 | import pkg from './package.json'; 5 | 6 | export default { 7 | input: [ 8 | 'src/withClientScripts.tsx', 9 | 'src/ClientScript.tsx', 10 | 'src/ClientWidget.tsx', 11 | 'src/initWidgets.tsx' 12 | ], 13 | output: { 14 | dir: 'dist', 15 | format: 'cjs', 16 | sourcemap: true 17 | }, 18 | plugins: [typescript(), commonjs()], 19 | external: Object.keys(pkg.dependencies) 20 | .concat(Object.keys(pkg.peerDependencies)) 21 | .concat('next/dist/next-server/lib/document-context', 'path') 22 | }; 23 | -------------------------------------------------------------------------------- /packages/next-client-script/src/ClientScript.tsx: -------------------------------------------------------------------------------- 1 | import {DocumentContext} from 'next/dist/next-server/lib/document-context'; 2 | import {pathToRegexp} from 'path-to-regexp'; 3 | import React, {useContext, ScriptHTMLAttributes} from 'react'; 4 | import ClientScriptsByPath from './ClientScriptsByPath'; 5 | 6 | declare const CLIENT_SCRIPTS_BY_PATH: ClientScriptsByPath; 7 | 8 | const clientScriptsByPathRegex = Object.entries(CLIENT_SCRIPTS_BY_PATH).map( 9 | ([path, clientScript]) => ({ 10 | pathRegex: pathToRegexp(path), 11 | clientScript 12 | }) 13 | ); 14 | 15 | export default function ClientScript({ 16 | async = true, 17 | type = 'text/javascript', 18 | ...rest 19 | }: ScriptHTMLAttributes) { 20 | const context = useContext(DocumentContext); 21 | 22 | // Query params and hashes are already removed from this path. 23 | const pagePath = context.__NEXT_DATA__.page; 24 | 25 | const match = clientScriptsByPathRegex.find((cur) => 26 | cur.pathRegex.test(pagePath) 27 | ); 28 | 29 | if (!match) { 30 | return null; 31 | } 32 | 33 | return ( 34 |