├── .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 |
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 | 
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 |
69 |
70 |
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 |
--------------------------------------------------------------------------------