├── .npmrc ├── public ├── favicon.ico ├── vercel.svg ├── thirteen.svg └── next.svg ├── components └── dynamic │ ├── ssr.jsx │ └── browser.jsx ├── jsconfig.json ├── pages ├── _app.js ├── api │ └── hello.js ├── _document.js └── index.js ├── .gitignore ├── patches └── preact+10.28.0.patch ├── package.json ├── next.config.js ├── styles ├── globals.css └── Home.module.css └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps = true -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfre/next-preact/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /components/dynamic/ssr.jsx: -------------------------------------------------------------------------------- 1 | export default function DynamicSSR() { 2 | return

SSR Dynamic Component

; 3 | } -------------------------------------------------------------------------------- /components/dynamic/browser.jsx: -------------------------------------------------------------------------------- 1 | export default function DyanmicBrowser() { 2 | return

Browser Dynamic Component

; 3 | } -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./*"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | 3 | export default function App({ Component, pageProps }) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default function handler(req, res) { 4 | res.status(200).json({ name: 'John Doe' }) 5 | } 6 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | -------------------------------------------------------------------------------- /patches/preact+10.28.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/preact/package.json b/node_modules/preact/package.json 2 | index 4ec0986..db9fa63 100644 3 | --- a/node_modules/preact/package.json 4 | +++ b/node_modules/preact/package.json 5 | @@ -93,7 +93,7 @@ 6 | }, 7 | "./compat/server.browser": { 8 | "types": "./compat/server.d.ts", 9 | - "default": "./compat/server.browser.js" 10 | + "default": "./compat/server.js" 11 | }, 12 | "./compat/jsx-runtime": { 13 | "types": "./jsx-runtime/src/index.d.ts", 14 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-preact", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --webpack", 7 | "build": "next build --webpack", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "patch-package" 11 | }, 12 | "dependencies": { 13 | "next": "^16.0.10", 14 | "next-plugin-preact": "^3.0.7", 15 | "patch-package": "^8.0.1", 16 | "preact": "^10.28.0", 17 | "preact-render-to-string": "^6.6.4", 18 | "react": "npm:@preact/compat@^18.3.1", 19 | "react-dom": "npm:@preact/compat@^18.3.1", 20 | "react-ssr-prepass": "npm:preact-ssr-prepass@^1.2.2" 21 | }, 22 | "overrides": { 23 | "@prefresh/next": "1.7.0", 24 | "@prefresh/webpack": "4.0.2" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { WEBPACK_LAYERS } = require('next/dist/lib/constants'); 2 | 3 | module.exports = require('next-plugin-preact')({ 4 | experimental: { 5 | esmExternals: false // https://github.com/preactjs/next-plugin-preact/issues/61 6 | }, 7 | webpack(config, options) { 8 | const { dev, isServer } = options; 9 | if (dev && !isServer) { 10 | /* 11 | Since v15.2.0-canary.56, Next added webpack layers in this PR: 12 | https://github.com/vercel/next.js/pull/75878 13 | Any plugin that adds additional entries needs to specify the right layer. 14 | Otherwise, the entry will be considered a duplicate in the bundle. 15 | This is mostly fine, except that for Prefresh to work, it needs to set options.vnode on the same module in memory. 16 | Without a layer, the options.vnode gets set in a different object in memory than the preact module the app loads. 17 | */ 18 | const prefreshPlugin = config.plugins.find((plugin) => plugin.constructor.name === 'ReloadPlugin' && plugin.options?.runsInNextJs); 19 | 20 | // entryOptions requires @prefresh/webpack 4.0.2+ 21 | if (prefreshPlugin && prefreshPlugin.options.hasOwnProperty('entryOptions')) { 22 | prefreshPlugin.options.entryOptions = { 23 | ...prefreshPlugin.options.entryOptions, 24 | layer: WEBPACK_LAYERS.pagesDirBrowser 25 | }; 26 | } else { 27 | config.plugins.unshift({ 28 | apply(compiler) { 29 | compiler.hooks.thisCompilation.tap('Prefresh', (compilation) => { 30 | // !IMPORTANT: This changes every entry, upgrade to @prefresh/webpack 4.0.2 to apply the above behavior 31 | compilation.globalEntry.options.layer = WEBPACK_LAYERS.pagesDirBrowser; 32 | }); 33 | } 34 | }); 35 | } 36 | } 37 | return config; 38 | } 39 | }); -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --max-width: 1100px; 3 | --border-radius: 12px; 4 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 5 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 6 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; 7 | 8 | --foreground-rgb: 0, 0, 0; 9 | --background-start-rgb: 214, 219, 220; 10 | --background-end-rgb: 255, 255, 255; 11 | 12 | --primary-glow: conic-gradient( 13 | from 180deg at 50% 50%, 14 | #16abff33 0deg, 15 | #0885ff33 55deg, 16 | #54d6ff33 120deg, 17 | #0071ff33 160deg, 18 | transparent 360deg 19 | ); 20 | --secondary-glow: radial-gradient( 21 | rgba(255, 255, 255, 1), 22 | rgba(255, 255, 255, 0) 23 | ); 24 | 25 | --tile-start-rgb: 239, 245, 249; 26 | --tile-end-rgb: 228, 232, 233; 27 | --tile-border: conic-gradient( 28 | #00000080, 29 | #00000040, 30 | #00000030, 31 | #00000020, 32 | #00000010, 33 | #00000010, 34 | #00000080 35 | ); 36 | 37 | --callout-rgb: 238, 240, 241; 38 | --callout-border-rgb: 172, 175, 176; 39 | --card-rgb: 180, 185, 188; 40 | --card-border-rgb: 131, 134, 135; 41 | } 42 | 43 | @media (prefers-color-scheme: dark) { 44 | :root { 45 | --foreground-rgb: 255, 255, 255; 46 | --background-start-rgb: 0, 0, 0; 47 | --background-end-rgb: 0, 0, 0; 48 | 49 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); 50 | --secondary-glow: linear-gradient( 51 | to bottom right, 52 | rgba(1, 65, 255, 0), 53 | rgba(1, 65, 255, 0), 54 | rgba(1, 65, 255, 0.3) 55 | ); 56 | 57 | --tile-start-rgb: 2, 13, 46; 58 | --tile-end-rgb: 2, 5, 19; 59 | --tile-border: conic-gradient( 60 | #ffffff80, 61 | #ffffff40, 62 | #ffffff30, 63 | #ffffff20, 64 | #ffffff10, 65 | #ffffff10, 66 | #ffffff80 67 | ); 68 | 69 | --callout-rgb: 20, 20, 20; 70 | --callout-border-rgb: 108, 108, 108; 71 | --card-rgb: 100, 100, 100; 72 | --card-border-rgb: 200, 200, 200; 73 | } 74 | } 75 | 76 | * { 77 | box-sizing: border-box; 78 | padding: 0; 79 | margin: 0; 80 | } 81 | 82 | html, 83 | body { 84 | max-width: 100vw; 85 | overflow-x: hidden; 86 | } 87 | 88 | body { 89 | color: rgb(var(--foreground-rgb)); 90 | background: linear-gradient( 91 | to bottom, 92 | transparent, 93 | rgb(var(--background-end-rgb)) 94 | ) 95 | rgb(var(--background-start-rgb)); 96 | } 97 | 98 | a { 99 | color: inherit; 100 | text-decoration: none; 101 | } 102 | 103 | @media (prefers-color-scheme: dark) { 104 | html { 105 | color-scheme: dark; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next with Preact 2 | 3 | ## Works up to Next.js 16.0.10 ✅ 4 | 5 | > Since Next 13.5, Next compiles some of its dist folder into the "dist/compiled/next-server" folder only when publishing to NPM. This means the file "dist/server/render.js" is not the one that runs, and thus the patch does nothing. Instead the patches would have to be applied to "dist/compiled/next-server/pages.runtime.dev.js" and "pages.runtime.prod.js" accordingly. These files are minified so they're much harder to modify. The approach changed to instead patch Preact with a custom version of `renderToReadableStream` until Preact added their own version. 6 | 7 | ![](https://media3.giphy.com/media/s239QJIh56sRW/giphy.gif) 8 | 9 | _...Sighs in JavaScript..._ 10 | 11 | If you're unable to use the `app` directory with React Server Components, streaming and other React 18+ features, but still want/need improvements to Next and its utilities, Next with Preact is for you. 12 | 13 | > Preact added `renderToReadableStream` in version [6.5.0](https://github.com/preactjs/preact-render-to-string/pull/296). However, App Router is stil incompatible with Preact due to some internal mismatching within hooks. 14 | 15 | ## ⚠️ Disclaimer ⚠️ 16 | 17 | My approach was to achieve this with the least amount of changes to Next.js pointing back to Next 12.3.4. So I can't promise that this will work for all your use cases. I also can't promise that this will automatically work in future versions. Proceed with caution. 18 | 19 | ## Demo 20 | 21 | [Live URL](https://7s2mt8-3000.csb.app/) | 22 | [CodeSandbox](https://codesandbox.io/p/github/lfre/next-13-preact/main) 23 | 24 | ## Dependencies 25 | 26 | - [next-preact-plugin](https://github.com/preactjs/next-plugin-preact) 27 | - [patch-package](https://github.com/ds300/patch-package) 28 | 29 | ## Usage 30 | 31 | Follow the instructions in [next-preact-plugin](https://github.com/preactjs/next-plugin-preact), and disable `esmExternals` in your `next.config.js` to resolve this [issue](https://github.com/preactjs/next-plugin-preact/issues/61): 32 | 33 | ```js 34 | require('next-plugin-preact')({ 35 | experimental: { 36 | esmExternals: false 37 | } 38 | }); 39 | ``` 40 | 41 | ### Hot Reloading 42 | 43 | Since Next.js 15.2.0, Next added [webpack layers](https://github.com/vercel/next.js/pull/75878), and `@prefresh/next` stopped working. This can be resolved by adding the layer to the webpack compilation. Refer to `next.config.js` for the `webpack` changes needed and more information. 44 | 45 | --- 46 | 47 | Copy the `patches` directory to your project root. 48 | 49 | > If you're using `patch-package` already, add the `.patch` included here to your `patches` directory. 50 | 51 | Install [`patch-package`](https://www.npmjs.com/package/patch-package), and run `patch-package` in `postinstall`: 52 | 53 | ```json 54 | { 55 | "scripts": { 56 | "postinstall": "patch-package" 57 | } 58 | } 59 | ``` 60 | ## Patches 61 | 62 | - Add `server.browser` export to `preact/package.json`. Won't be done in Preact. [Issue](https://github.com/preactjs/preact/issues/3787) 63 | - Export added in 10.28.0 https://github.com/preactjs/preact/pull/4940. Patch still needed to point to server.js instead of server.browser.js. 64 | 65 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Image from 'next/image' 3 | import { Inter } from 'next/font/google' 4 | import styles from '@/styles/Home.module.css' 5 | import dynamic from 'next/dynamic'; 6 | 7 | const SSRDynamic = dynamic( 8 | () => import('@/components/dynamic/ssr')); 9 | const BrowserDynamic = dynamic( 10 | () => import('@/components/dynamic/browser'), { ssr: false }); 11 | 12 | const inter = Inter({ subsets: ['latin'] }) 13 | 14 | export default function Home() { 15 | return ( 16 | <> 17 | 18 | Create Next App 19 | 20 | 21 | 22 | 23 |
24 |
25 |

26 | Get started by editing  27 | pages/index.js 28 |

29 | 30 | 31 | 48 |
49 | 50 |
51 | Next.js Logo 59 |
60 | 13 67 |
68 |
69 | 70 |
71 | 77 |

78 | Docs -> 79 |

80 |

81 | Find in-depth information about Next.js features and API. 82 |

83 |
84 | 85 | 91 |

92 | Learn -> 93 |

94 |

95 | Learn about Next.js in an interactive course with quizzes! 96 |

97 |
98 | 99 | 105 |

106 | Templates -> 107 |

108 |

109 | Discover and deploy boilerplate example Next.js projects. 110 |

111 |
112 | 113 | 119 |

120 | Deploy -> 121 |

122 |

123 | Instantly deploy your Next.js site to a shareable URL 124 | with Vercel. 125 |

126 |
127 |
128 |
129 | 130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | padding: 6rem; 7 | min-height: 100vh; 8 | } 9 | 10 | .description { 11 | display: inherit; 12 | justify-content: inherit; 13 | align-items: inherit; 14 | font-size: 0.85rem; 15 | max-width: var(--max-width); 16 | width: 100%; 17 | z-index: 2; 18 | font-family: var(--font-mono); 19 | } 20 | 21 | .description a { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | gap: 0.5rem; 26 | } 27 | 28 | .description p { 29 | position: relative; 30 | margin: 0; 31 | padding: 1rem; 32 | background-color: rgba(var(--callout-rgb), 0.5); 33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3); 34 | border-radius: var(--border-radius); 35 | } 36 | 37 | .code { 38 | font-weight: 700; 39 | font-family: var(--font-mono); 40 | } 41 | 42 | .grid { 43 | display: grid; 44 | grid-template-columns: repeat(4, minmax(25%, auto)); 45 | width: var(--max-width); 46 | max-width: 100%; 47 | } 48 | 49 | .card { 50 | padding: 1rem 1.2rem; 51 | border-radius: var(--border-radius); 52 | background: rgba(var(--card-rgb), 0); 53 | border: 1px solid rgba(var(--card-border-rgb), 0); 54 | transition: background 200ms, border 200ms; 55 | } 56 | 57 | .card span { 58 | display: inline-block; 59 | transition: transform 200ms; 60 | } 61 | 62 | .card h2 { 63 | font-weight: 600; 64 | margin-bottom: 0.7rem; 65 | } 66 | 67 | .card p { 68 | margin: 0; 69 | opacity: 0.6; 70 | font-size: 0.9rem; 71 | line-height: 1.5; 72 | max-width: 30ch; 73 | } 74 | 75 | .center { 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | position: relative; 80 | padding: 4rem 0; 81 | } 82 | 83 | .center::before { 84 | background: var(--secondary-glow); 85 | border-radius: 50%; 86 | width: 480px; 87 | height: 360px; 88 | margin-left: -400px; 89 | } 90 | 91 | .center::after { 92 | background: var(--primary-glow); 93 | width: 240px; 94 | height: 180px; 95 | z-index: -1; 96 | } 97 | 98 | .center::before, 99 | .center::after { 100 | content: ''; 101 | left: 50%; 102 | position: absolute; 103 | filter: blur(45px); 104 | transform: translateZ(0); 105 | } 106 | 107 | .logo, 108 | .thirteen { 109 | position: relative; 110 | } 111 | 112 | .thirteen { 113 | display: flex; 114 | justify-content: center; 115 | align-items: center; 116 | width: 75px; 117 | height: 75px; 118 | padding: 25px 10px; 119 | margin-left: 16px; 120 | transform: translateZ(0); 121 | border-radius: var(--border-radius); 122 | overflow: hidden; 123 | box-shadow: 0px 2px 8px -1px #0000001a; 124 | } 125 | 126 | .thirteen::before, 127 | .thirteen::after { 128 | content: ''; 129 | position: absolute; 130 | z-index: -1; 131 | } 132 | 133 | /* Conic Gradient Animation */ 134 | .thirteen::before { 135 | animation: 6s rotate linear infinite; 136 | width: 200%; 137 | height: 200%; 138 | background: var(--tile-border); 139 | } 140 | 141 | /* Inner Square */ 142 | .thirteen::after { 143 | inset: 0; 144 | padding: 1px; 145 | border-radius: var(--border-radius); 146 | background: linear-gradient( 147 | to bottom right, 148 | rgba(var(--tile-start-rgb), 1), 149 | rgba(var(--tile-end-rgb), 1) 150 | ); 151 | background-clip: content-box; 152 | } 153 | 154 | /* Enable hover only on non-touch devices */ 155 | @media (hover: hover) and (pointer: fine) { 156 | .card:hover { 157 | background: rgba(var(--card-rgb), 0.1); 158 | border: 1px solid rgba(var(--card-border-rgb), 0.15); 159 | } 160 | 161 | .card:hover span { 162 | transform: translateX(4px); 163 | } 164 | } 165 | 166 | @media (prefers-reduced-motion) { 167 | .thirteen::before { 168 | animation: none; 169 | } 170 | 171 | .card:hover span { 172 | transform: none; 173 | } 174 | } 175 | 176 | /* Mobile */ 177 | @media (max-width: 700px) { 178 | .content { 179 | padding: 4rem; 180 | } 181 | 182 | .grid { 183 | grid-template-columns: 1fr; 184 | margin-bottom: 120px; 185 | max-width: 320px; 186 | text-align: center; 187 | } 188 | 189 | .card { 190 | padding: 1rem 2.5rem; 191 | } 192 | 193 | .card h2 { 194 | margin-bottom: 0.5rem; 195 | } 196 | 197 | .center { 198 | padding: 8rem 0 6rem; 199 | } 200 | 201 | .center::before { 202 | transform: none; 203 | height: 300px; 204 | } 205 | 206 | .description { 207 | font-size: 0.8rem; 208 | } 209 | 210 | .description a { 211 | padding: 1rem; 212 | } 213 | 214 | .description p, 215 | .description div { 216 | display: flex; 217 | justify-content: center; 218 | position: fixed; 219 | width: 100%; 220 | } 221 | 222 | .description p { 223 | align-items: center; 224 | inset: 0 0 auto; 225 | padding: 2rem 1rem 1.4rem; 226 | border-radius: 0; 227 | border: none; 228 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); 229 | background: linear-gradient( 230 | to bottom, 231 | rgba(var(--background-start-rgb), 1), 232 | rgba(var(--callout-rgb), 0.5) 233 | ); 234 | background-clip: padding-box; 235 | backdrop-filter: blur(24px); 236 | } 237 | 238 | .description div { 239 | align-items: flex-end; 240 | pointer-events: none; 241 | inset: auto 0 0; 242 | padding: 2rem; 243 | height: 200px; 244 | background: linear-gradient( 245 | to bottom, 246 | transparent 0%, 247 | rgb(var(--background-end-rgb)) 40% 248 | ); 249 | z-index: 1; 250 | } 251 | } 252 | 253 | /* Tablet and Smaller Desktop */ 254 | @media (min-width: 701px) and (max-width: 1120px) { 255 | .grid { 256 | grid-template-columns: repeat(2, 50%); 257 | } 258 | } 259 | 260 | @media (prefers-color-scheme: dark) { 261 | .vercelLogo { 262 | filter: invert(1); 263 | } 264 | 265 | .logo, 266 | .thirteen img { 267 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); 268 | } 269 | } 270 | 271 | @keyframes rotate { 272 | from { 273 | transform: rotate(360deg); 274 | } 275 | to { 276 | transform: rotate(0deg); 277 | } 278 | } 279 | --------------------------------------------------------------------------------