jskelet 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1202
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1232
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -0
  40. package/src/client/cache-panel/login.html +74 -71
  41. package/src/client/cache-panel/panel.css +756 -740
  42. package/src/client/cache-panel/panel.html +308 -307
  43. package/src/client/cache-panel/panel.js +915 -808
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -738
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,359 +1,359 @@
1
- # 11 — Migrating from Next.js
2
-
3
- This document explains how to move a project using the Next.js App Router over
4
- to JSkelet: a table of concept and API equivalents, an explicit list of what
5
- cannot be migrated, and a step-by-step plan. JSkelet's surface was deliberately
6
- modeled on the subset of Next that people actually use — concepts like the
7
- `next.config` syntax, the Metadata API, `notFound()`, `revalidate` and `cache()`
8
- will feel familiar. The *reasons* behind the differences are in
9
- [02-architecture.md](./02-architecture.md).
10
-
11
- ## Equivalence table
12
-
13
- ### Configuration
14
-
15
- | Next.js | JSkelet | Note |
16
- | --- | --- | --- |
17
- | `next.config.mjs` | `jskelet.config.mjs` | Same spirit, smaller surface ([07](./07-configuration.md)) |
18
- | `headers()` | `headers()` | Same shape: `{ source, headers: [{ key, value }] }` |
19
- | `redirects()` | `redirects()` | `permanent` → 308, otherwise 307; can be overridden with `statusCode` |
20
- | `rewrites()` | `rewrites()` | There are `beforeFiles` / `afterFiles` phases; no `fallback` |
21
- | `compress: true` | Automatic | brotli + gzip via `node:zlib` |
22
- | `images.deviceSizes` | `images.widths` | Build-time webp generation ([08](./08-build.md)) |
23
- | `NEXT_PUBLIC_*` | `clientEnv: [...]` | Which key is exposed is clear from the config, not from the name |
24
- | `experimental.*` | — | None |
25
-
26
- ### Routing and rendering
27
-
28
- | Next.js | JSkelet | Note |
29
- | --- | --- | --- |
30
- | `app/page.js` (file-based routing) | `app.get(...)` inside `routes/*.mjs` | The order is written explicitly ([03](./03-routing.md)) |
31
- | `app/[slug]/page.js` | `app.get("/:slug", route(...))` | Express pattern syntax |
32
- | `params`, `searchParams` | `ctx.params`, `ctx.query` | The controller's single argument |
33
- | `layout.js` | `views/layout.ejs` + `hooks.layoutContext()` | A single layout; no nested layouts |
34
- | Server component (RSC) | Controller + EJS template + `views/components/**` | A function returns an HTML string |
35
- | Client component (`"use client"`) | Island (`data-island` + `mount`) | The whole page is not hydrated ([05](./05-islands.md)) |
36
- | `notFound()` | `notFound()` | Same name, same control flow |
37
- | `redirect()` | `redirect()` (307) | For permanent, `permanentRedirect()` (308) |
38
- | `not-found.js` | `hooks.notFound()` | Returns a page definition |
39
- | `error.js` | Express error handler | The framework returns minimal HTML for a 500 |
40
- | `loading.js` / Suspense | — | The server HTML is complete; no skeleton needed |
41
- | Streaming SSR | — | The response is a single chunk |
42
- | `generateMetadata()` | Controller `metadata` + `hooks.metadata()` | Same field names ([04](./04-rendering.md)) |
43
- | `generateStaticParams()` | `hooks.prewarmPaths()` | Warming at startup time, not build time |
44
- | Route Handlers (`route.js`) | A plain Express handler | `app.get/post(...)` |
45
- | Middleware (`middleware.ts`) | Express middleware + config `rewrites`/`headers`/`redirects` | `app.use(...)` |
46
-
47
- ### Data and cache
48
-
49
- | Next.js | JSkelet | Note |
50
- | --- | --- | --- |
51
- | `export const revalidate = 60` | `route(controller, { revalidate: 60 })` | Or `cache().html` ([06](./06-caching.md)) |
52
- | ISR (prerender written to disk) | In-memory TTL cache + stale-while-revalidate | Nothing is written to disk |
53
- | `fetch(..., { next: { revalidate } })` | — | The cache is at page level |
54
- | `unstable_cache` | — | No cross-request data cache; there is a page cache |
55
- | React `cache()` | `cache()` | Same behavior: in-request memoization |
56
- | `revalidatePath()` | `invalidateHtmlCache("/news/:slug")` | A path, a pattern or a regexp; stales rather than deletes by default |
57
- | `revalidateTag()` | `clearDataCache("news:")` | No tags to declare: the dependency is observed during the render ([06](./06-caching.md)) |
58
- | `cookies()`, `headers()` | `ctx.req.headers`, `ctx.req.cookies`* | Direct access to the Express object |
59
- | `dynamic = "force-dynamic"` | Not passing `revalidate` | Which means the cache is off |
60
-
61
- \* Express 5 does not parse cookies on its own; add `cookie-parser` or read the
62
- header manually.
63
-
64
- ### Components and helpers
65
-
66
- | Next.js | JSkelet | Note |
67
- | --- | --- | --- |
68
- | `next/link` | `link({ href, text })` — `jskelet/tags` | `title` automatic, `rel`/`target` automatic for external links |
69
- | `next/link` prefetching | `navigation: { prefetch, prerender }` | Speculation Rules; no client runtime ([07](./07-configuration.md)) |
70
- | `next/image` | `image({ src, alt, priority })` — `jskelet/tags` | `srcset` from the build manifest |
71
- | `next/font/google` | `fonts: [{ family, weights }]` | Self-hosted woff2, committed |
72
- | `@phosphor-icons/react` | `icon({ name, weight })` — `jskelet/tags` | Build-time SVG sprite |
73
- | `react-dom` preconnect/preload | `preconnect: [...]` + `headHints()` | ([04](./04-rendering.md)) |
74
- | `clsx` | `cx()` — `jskelet/html` | — |
75
- | `cn()` (clsx + tailwind-merge) | `cn()` — `jskelet/html` | Same behavior |
76
- | JSX automatic escaping | `esc()` — `jskelet/html` | **You have to call it yourself** |
77
- | React Context | `createStore()` — `jskelet/client` | Minimal pub/sub |
78
- | `useState` / `useEffect` | Plain JS inside the island's `mount()` | — |
79
- | `useSyncExternalStore` | `store.subscribe()` | — |
80
- | `<Script>` | A `<script>` in the layout, or an island | — |
81
-
82
- ### What has no equivalent
83
-
84
- Account for these from the start in your migration plan:
85
-
86
- - **React itself.** Components turn into functions that return HTML strings. No
87
- JSX, no hooks, no virtual DOM.
88
- - **TypeScript.** The project is plain JS + JSDoc. With `checkJs: true` in
89
- `jsconfig.json` you get type checking from the editor.
90
- - **Nested layouts.** There is a single layout; you share common sections with
91
- EJS `include` or component functions.
92
- - **Streaming / Suspense / partial prerendering.** The response is produced as a
93
- single chunk.
94
- - **Client-side routing.** Navigation is a real page load. Because the server
95
- HTML comes from the cache it is very fast in practice, but there are no SPA
96
- transitions. What closes the gap is the `navigation` section:
97
- prefetch/prerender prepares the document before the click, and
98
- `viewTransition` smooths the transition
99
- ([07](./07-configuration.md)).
100
- - **Server Actions.** Form submissions are ordinary `app.post(...)` handlers.
101
- - **Automatic image optimization (at request time).** Optimization happens at
102
- build time and only covers local images under `public/`; remote images are
103
- emitted as-is.
104
-
105
- ## A side-by-side example
106
-
107
- **Next.js (App Router):**
108
-
109
- ```jsx
110
- // app/news/[slug]/page.jsx
111
- import { notFound } from "next/navigation";
112
- import Image from "next/image";
113
- import { getArticle } from "@/lib/api";
114
-
115
- export const revalidate = 300;
116
-
117
- export async function generateMetadata({ params }) {
118
- const article = await getArticle(params.slug);
119
- return {
120
- title: article?.title,
121
- description: article?.summary,
122
- alternates: { canonical: `/news/${params.slug}` },
123
- };
124
- }
125
-
126
- export default async function Page({ params }) {
127
- const article = await getArticle(params.slug);
128
- if (!article) notFound();
129
-
130
- return (
131
- <article className="wrapper">
132
- <h1 className="text-3xl font-bold">{article.title}</h1>
133
- <Image src={article.cover} alt={article.title} priority width={1200} height={630} />
134
- <div dangerouslySetInnerHTML={{ __html: article.body }} />
135
- </article>
136
- );
137
- }
138
- ```
139
-
140
- **JSkelet:**
141
-
142
- ```js
143
- // routes/50-news.mjs
144
- import { getArticle } from "@/lib/api.js";
145
-
146
- export default function register(app, { route, notFound }) {
147
- app.get(
148
- "/news/:slug",
149
- route(
150
- async ({ params }) => {
151
- const article = await getArticle(params.slug);
152
- if (!article) notFound();
153
-
154
- return {
155
- view: "pages/article",
156
- data: { article },
157
- metadata: {
158
- title: article.title,
159
- description: article.summary,
160
- canonical: `/news/${params.slug}`,
161
- openGraph: { type: "article", image: article.cover },
162
- },
163
- };
164
- },
165
- { revalidate: 300 },
166
- ),
167
- );
168
- }
169
- ```
170
-
171
- ```ejs
172
- <%# views/pages/article.ejs %>
173
- <article class="wrapper">
174
- <h1 class="text-3xl font-bold"><%= article.title %></h1>
175
- <%- image({ src: article.cover, alt: article.title, priority: true, width: 1200, height: 630 }) %>
176
- <div><%- article.body %></div>
177
- </article>
178
- ```
179
-
180
- Wrapping `getArticle` with `cache()` makes sure that if
181
- `hooks.layoutContext()` asks for the same article in the same render, only a
182
- single upstream request is made ([06-caching.md](./06-caching.md)).
183
-
184
- ## Step-by-step plan
185
-
186
- ### 1. Set up the skeleton (half a day)
187
-
188
- Run `npx jskelet init` in a new directory and watch `jskelet dev` come up. Leave
189
- the existing Next project as it is; let the migration run in parallel.
190
-
191
- Carry over the `paths` aliases from your `jsconfig.json` — prefixes like `@/`
192
- work the same way both on the server and in the bundle
193
- ([02-architecture.md](./02-architecture.md)).
194
-
195
- ### 2. Translate `next.config.mjs` (1-2 hours)
196
-
197
- The `headers()`, `redirects()` and `rewrites()` sections are copied almost
198
- verbatim. Check the pattern syntax: JSkelet supports the `:slug`, `:path*`,
199
- `/a-:b` and `/:path*.svg` forms; more complex `path-to-regexp` expressions are
200
- not supported and produce a warning ([07-configuration.md](./07-configuration.md)).
201
-
202
- Move your `NEXT_PUBLIC_*` variables into the `clientEnv` list and simplify their
203
- names (the prefix no longer carries meaning).
204
-
205
- ### 3. Move the data layer (the easiest step)
206
-
207
- The API client and data functions under `lib/` usually do not depend on React;
208
- they are copied as-is. Make two changes:
209
-
210
- - Use `import { cache } from "jskelet"` instead of React's `cache()`.
211
- - Call `reportUpstreamFailure({ status, path })` on failed upstream responses.
212
- This prevents pages produced with missing data from being written to the cache
213
- ([06-caching.md](./06-caching.md)).
214
-
215
- ### 4. Set up the layout (half a day)
216
-
217
- Translate `app/layout.jsx` into `views/layout.ejs`. Copying the framework's
218
- default layout (`node_modules/jskelet/src/templates/layout.ejs`) and editing it
219
- is the fastest path.
220
-
221
- If you fetch data inside `layout.jsx` (navigation, site settings), move it into
222
- `hooks.layoutContext()`: it runs in parallel with the body render, and every
223
- field it returns becomes a layout local.
224
-
225
- Put your global metadata defaults (`titleTemplate`, `siteUrl`, `description`)
226
- into `hooks.metadata()`.
227
-
228
- ### 5. Translate the components (the longest step)
229
-
230
- Every React component turns into a function:
231
-
232
- ```jsx
233
- // Before
234
- export function Badge({ label, tone = "neutral", className }) {
235
- return <span className={cn("rounded px-2 py-1", TONES[tone], className)}>{label}</span>;
236
- }
237
- ```
238
-
239
- ```js
240
- // After — views/components/badge.js
241
- import { attrs, cn, esc } from "jskelet/html";
242
-
243
- export function badge({ label, tone = "neutral", class: className }) {
244
- return `<span${attrs({ class: cn("rounded px-2 py-1", TONES[tone], className) })}>${esc(label)}</span>`;
245
- }
246
- ```
247
-
248
- Things to watch out for:
249
-
250
- - **Escaping is now on you.** JSX escaped automatically; here you must call
251
- `esc()` when printing external data.
252
- - **`className` → `class`.** Since `class` is a reserved word in JS, rename it in
253
- the props as `class: className`.
254
- - **An `html` field instead of children.** Nested content is passed as a string.
255
- - Every function you place under `views/components/**` as a named export can be
256
- used in templates without importing it
257
- ([04-rendering.md](./04-rendering.md)).
258
-
259
- Keep components small and pure; leave data fetching in the controller.
260
-
261
- ### 6. Migrate the pages (hours per page)
262
-
263
- Every `page.jsx` splits into a controller plus an EJS template. File them with
264
- the order in mind:
265
-
266
- ```
267
- routes/
268
- ├── 00-health.mjs health check
269
- ├── 10-pages.mjs static paths: /, /about
270
- ├── 50-news.mjs /news/:slug
271
- └── 99-catch-all.mjs /:slug (if any, last of all)
272
- ```
273
-
274
- The places where you used `generateStaticParams()` turn into
275
- `hooks.prewarmPaths()`. If you have a function that produces the sitemap, use
276
- the same one.
277
-
278
- Move your `export const revalidate` values either into `route()`'s second
279
- argument or, to manage them from a single place, into `cache().html` patterns.
280
-
281
- ### 7. Turn client components into islands (hours per page)
282
-
283
- Every `"use client"` component becomes an island. The process:
284
-
285
- 1. Move the component's **static** output into the server template. Everything
286
- visible on first render must be in the HTML.
287
- 2. Write the remaining behavior inside `mount(element, props)`: `useState`
288
- becomes a local variable, `useEffect` a direct call, event handlers
289
- `on()`/`onClick()`.
290
- 3. Pass props as JSON with `data-island-props`.
291
- 4. Add it to the `registerAll()` map in the entry.
292
- 5. Choose the binding strategy: the default (visibility), `data-island-eager`
293
- (global behavior) or `data-island-idle` (heavy and non-critical).
294
-
295
- For components using Context, `createStore()` is the closest equivalent
296
- ([05-islands.md](./05-islands.md)).
297
-
298
- **This is where the biggest win of this step lies:** the hydrated area is not
299
- the whole page, only the parts that are genuinely interactive.
300
-
301
- ### 8. Move the CSS (1-2 hours)
302
-
303
- If your Tailwind configuration is already in v4 format, `styles/globals.css`
304
- stays almost the same. The one critical addition is the `@source` directives:
305
-
306
- ```css
307
- @import "tailwindcss" source(none);
308
-
309
- @source "../views";
310
- @source "../client";
311
- @source "../routes";
312
- @source "../lib";
313
- ```
314
-
315
- Without these, the classes used in templates (especially variants like
316
- `data-[state=open]:…`) are silently dropped
317
- ([08-build.md](./08-build.md)).
318
-
319
- If you use `next/font`, add `fonts: [{ family, weights }]` and write the
320
- `@font-face` blocks by hand; the generated files sit under `public/fonts/` with
321
- stable names.
322
-
323
- ### 9. Verify and measure
324
-
325
- - Browse with `jskelet dev` and confirm there are no errors in the dev overlay.
326
- - On the report page (`/__jskelet/dev/report`), look at each page's Web Vitals
327
- measurements, SSR size and island status ([09-dev-tools.md](./09-dev-tools.md)).
328
- - Clear the missing-icon warnings.
329
- - Compare the sizes in the `jskelet build` output with your old Next bundle.
330
- - Check that the `X-JSkelet-Cache` header returns `HIT` on the pages you expect.
331
-
332
- ### 10. Go live
333
-
334
- Go through the checklist in [10-deployment.md](./10-deployment.md). Keeping the
335
- old Next setup alongside for a while and shifting traffic gradually is useful,
336
- especially for verifying that the redirect rules are correct.
337
-
338
- ## Common mistakes during migration
339
-
340
- - **Forgetting `esc()`.** Writing `${value}` out of JSX habit means XSS. In
341
- templates, mind the distinction between `<%= %>` (escaped) and `<%- %>` (raw).
342
- - **Opening a new directory without adding `@source`.** The classes are silently
343
- dropped.
344
- - **Putting the catch-all route in the wrong order.** `/:slug` always goes last.
345
- - **Making the whole page an island.** The win comes from the server HTML being
346
- complete; bind the island only to the genuinely interactive part.
347
- - **Forgetting to pass `revalidate`.** The cache stays off, every request is
348
- rendered, and `X-JSkelet-Cache: MISS` is returned.
349
- - **Not calling `reportUpstreamFailure()`.** When the upstream goes down, the
350
- page produced with missing data is served for the entire TTL.
351
- - **Putting a secret key in `clientEnv`.** The values sit in the bundle as plain
352
- text.
353
-
354
- ## What's next
355
-
356
- - The reasons behind the architectural decisions:
357
- [02-architecture.md](./02-architecture.md)
358
- - Details of the island model: [05-islands.md](./05-islands.md)
359
- - Configuration reference: [07-configuration.md](./07-configuration.md)
1
+ # 11 — Migrating from Next.js
2
+
3
+ This document explains how to move a project using the Next.js App Router over
4
+ to JSkelet: a table of concept and API equivalents, an explicit list of what
5
+ cannot be migrated, and a step-by-step plan. JSkelet's surface was deliberately
6
+ modeled on the subset of Next that people actually use — concepts like the
7
+ `next.config` syntax, the Metadata API, `notFound()`, `revalidate` and `cache()`
8
+ will feel familiar. The *reasons* behind the differences are in
9
+ [02-architecture.md](./02-architecture.md).
10
+
11
+ ## Equivalence table
12
+
13
+ ### Configuration
14
+
15
+ | Next.js | JSkelet | Note |
16
+ | --- | --- | --- |
17
+ | `next.config.mjs` | `jskelet.config.mjs` | Same spirit, smaller surface ([07](./07-configuration.md)) |
18
+ | `headers()` | `headers()` | Same shape: `{ source, headers: [{ key, value }] }` |
19
+ | `redirects()` | `redirects()` | `permanent` → 308, otherwise 307; can be overridden with `statusCode` |
20
+ | `rewrites()` | `rewrites()` | There are `beforeFiles` / `afterFiles` phases; no `fallback` |
21
+ | `compress: true` | Automatic | brotli + gzip via `node:zlib` |
22
+ | `images.deviceSizes` | `images.widths` | Build-time webp generation ([08](./08-build.md)) |
23
+ | `NEXT_PUBLIC_*` | `clientEnv: [...]` | Which key is exposed is clear from the config, not from the name |
24
+ | `experimental.*` | — | None |
25
+
26
+ ### Routing and rendering
27
+
28
+ | Next.js | JSkelet | Note |
29
+ | --- | --- | --- |
30
+ | `app/page.js` (file-based routing) | `app.get(...)` inside `routes/*.mjs` | The order is written explicitly ([03](./03-routing.md)) |
31
+ | `app/[slug]/page.js` | `app.get("/:slug", route(...))` | Express pattern syntax |
32
+ | `params`, `searchParams` | `ctx.params`, `ctx.query` | The controller's single argument |
33
+ | `layout.js` | `views/layout.ejs` + `hooks.layoutContext()` | A single layout; no nested layouts |
34
+ | Server component (RSC) | Controller + EJS template + `views/components/**` | A function returns an HTML string |
35
+ | Client component (`"use client"`) | Island (`data-island` + `mount`) | The whole page is not hydrated ([05](./05-islands.md)) |
36
+ | `notFound()` | `notFound()` | Same name, same control flow |
37
+ | `redirect()` | `redirect()` (307) | For permanent, `permanentRedirect()` (308) |
38
+ | `not-found.js` | `hooks.notFound()` | Returns a page definition |
39
+ | `error.js` | Express error handler | The framework returns minimal HTML for a 500 |
40
+ | `loading.js` / Suspense | — | The server HTML is complete; no skeleton needed |
41
+ | Streaming SSR | — | The response is a single chunk |
42
+ | `generateMetadata()` | Controller `metadata` + `hooks.metadata()` | Same field names ([04](./04-rendering.md)) |
43
+ | `generateStaticParams()` | `hooks.prewarmPaths()` | Warming at startup time, not build time |
44
+ | Route Handlers (`route.js`) | A plain Express handler | `app.get/post(...)` |
45
+ | Middleware (`middleware.ts`) | Express middleware + config `rewrites`/`headers`/`redirects` | `app.use(...)` |
46
+
47
+ ### Data and cache
48
+
49
+ | Next.js | JSkelet | Note |
50
+ | --- | --- | --- |
51
+ | `export const revalidate = 60` | `route(controller, { revalidate: 60 })` | Or `cache().html` ([06](./06-caching.md)) |
52
+ | ISR (prerender written to disk) | In-memory TTL cache + stale-while-revalidate | Nothing is written to disk |
53
+ | `fetch(..., { next: { revalidate } })` | — | The cache is at page level |
54
+ | `unstable_cache` | — | No cross-request data cache; there is a page cache |
55
+ | React `cache()` | `cache()` | Same behavior: in-request memoization |
56
+ | `revalidatePath()` | `invalidateHtmlCache("/news/:slug")` | A path, a pattern or a regexp; stales rather than deletes by default |
57
+ | `revalidateTag()` | `clearDataCache("news:")` | No tags to declare: the dependency is observed during the render ([06](./06-caching.md)) |
58
+ | `cookies()`, `headers()` | `ctx.req.headers`, `ctx.req.cookies`* | Direct access to the Express object |
59
+ | `dynamic = "force-dynamic"` | Not passing `revalidate` | Which means the cache is off |
60
+
61
+ \* Express 5 does not parse cookies on its own; add `cookie-parser` or read the
62
+ header manually.
63
+
64
+ ### Components and helpers
65
+
66
+ | Next.js | JSkelet | Note |
67
+ | --- | --- | --- |
68
+ | `next/link` | `link({ href, text })` — `jskelet/tags` | `title` automatic, `rel`/`target` automatic for external links |
69
+ | `next/link` prefetching | `navigation: { prefetch, prerender }` | Speculation Rules; no client runtime ([07](./07-configuration.md)) |
70
+ | `next/image` | `image({ src, alt, priority })` — `jskelet/tags` | `srcset` from the build manifest |
71
+ | `next/font/google` | `fonts: [{ family, weights }]` | Self-hosted woff2, committed |
72
+ | `@phosphor-icons/react` | `icon({ name, weight })` — `jskelet/tags` | Build-time SVG sprite |
73
+ | `react-dom` preconnect/preload | `preconnect: [...]` + `headHints()` | ([04](./04-rendering.md)) |
74
+ | `clsx` | `cx()` — `jskelet/html` | — |
75
+ | `cn()` (clsx + tailwind-merge) | `cn()` — `jskelet/html` | Same behavior |
76
+ | JSX automatic escaping | `esc()` — `jskelet/html` | **You have to call it yourself** |
77
+ | React Context | `createStore()` — `jskelet/client` | Minimal pub/sub |
78
+ | `useState` / `useEffect` | Plain JS inside the island's `mount()` | — |
79
+ | `useSyncExternalStore` | `store.subscribe()` | — |
80
+ | `<Script>` | A `<script>` in the layout, or an island | — |
81
+
82
+ ### What has no equivalent
83
+
84
+ Account for these from the start in your migration plan:
85
+
86
+ - **React itself.** Components turn into functions that return HTML strings. No
87
+ JSX, no hooks, no virtual DOM.
88
+ - **TypeScript.** The project is plain JS + JSDoc. With `checkJs: true` in
89
+ `jsconfig.json` you get type checking from the editor.
90
+ - **Nested layouts.** There is a single layout; you share common sections with
91
+ EJS `include` or component functions.
92
+ - **Streaming / Suspense / partial prerendering.** The response is produced as a
93
+ single chunk.
94
+ - **Client-side routing.** Navigation is a real page load. Because the server
95
+ HTML comes from the cache it is very fast in practice, but there are no SPA
96
+ transitions. What closes the gap is the `navigation` section:
97
+ prefetch/prerender prepares the document before the click, and
98
+ `viewTransition` smooths the transition
99
+ ([07](./07-configuration.md)).
100
+ - **Server Actions.** Form submissions are ordinary `app.post(...)` handlers.
101
+ - **Automatic image optimization (at request time).** Optimization happens at
102
+ build time and only covers local images under `public/`; remote images are
103
+ emitted as-is.
104
+
105
+ ## A side-by-side example
106
+
107
+ **Next.js (App Router):**
108
+
109
+ ```jsx
110
+ // app/news/[slug]/page.jsx
111
+ import { notFound } from "next/navigation";
112
+ import Image from "next/image";
113
+ import { getArticle } from "@/lib/api";
114
+
115
+ export const revalidate = 300;
116
+
117
+ export async function generateMetadata({ params }) {
118
+ const article = await getArticle(params.slug);
119
+ return {
120
+ title: article?.title,
121
+ description: article?.summary,
122
+ alternates: { canonical: `/news/${params.slug}` },
123
+ };
124
+ }
125
+
126
+ export default async function Page({ params }) {
127
+ const article = await getArticle(params.slug);
128
+ if (!article) notFound();
129
+
130
+ return (
131
+ <article className="wrapper">
132
+ <h1 className="text-3xl font-bold">{article.title}</h1>
133
+ <Image src={article.cover} alt={article.title} priority width={1200} height={630} />
134
+ <div dangerouslySetInnerHTML={{ __html: article.body }} />
135
+ </article>
136
+ );
137
+ }
138
+ ```
139
+
140
+ **JSkelet:**
141
+
142
+ ```js
143
+ // routes/50-news.mjs
144
+ import { getArticle } from "@/lib/api.js";
145
+
146
+ export default function register(app, { route, notFound }) {
147
+ app.get(
148
+ "/news/:slug",
149
+ route(
150
+ async ({ params }) => {
151
+ const article = await getArticle(params.slug);
152
+ if (!article) notFound();
153
+
154
+ return {
155
+ view: "pages/article",
156
+ data: { article },
157
+ metadata: {
158
+ title: article.title,
159
+ description: article.summary,
160
+ canonical: `/news/${params.slug}`,
161
+ openGraph: { type: "article", image: article.cover },
162
+ },
163
+ };
164
+ },
165
+ { revalidate: 300 },
166
+ ),
167
+ );
168
+ }
169
+ ```
170
+
171
+ ```ejs
172
+ <%# views/pages/article.ejs %>
173
+ <article class="wrapper">
174
+ <h1 class="text-3xl font-bold"><%= article.title %></h1>
175
+ <%- image({ src: article.cover, alt: article.title, priority: true, width: 1200, height: 630 }) %>
176
+ <div><%- article.body %></div>
177
+ </article>
178
+ ```
179
+
180
+ Wrapping `getArticle` with `cache()` makes sure that if
181
+ `hooks.layoutContext()` asks for the same article in the same render, only a
182
+ single upstream request is made ([06-caching.md](./06-caching.md)).
183
+
184
+ ## Step-by-step plan
185
+
186
+ ### 1. Set up the skeleton (half a day)
187
+
188
+ Run `npx jskelet init` in a new directory and watch `jskelet dev` come up. Leave
189
+ the existing Next project as it is; let the migration run in parallel.
190
+
191
+ Carry over the `paths` aliases from your `jsconfig.json` — prefixes like `@/`
192
+ work the same way both on the server and in the bundle
193
+ ([02-architecture.md](./02-architecture.md)).
194
+
195
+ ### 2. Translate `next.config.mjs` (1-2 hours)
196
+
197
+ The `headers()`, `redirects()` and `rewrites()` sections are copied almost
198
+ verbatim. Check the pattern syntax: JSkelet supports the `:slug`, `:path*`,
199
+ `/a-:b` and `/:path*.svg` forms; more complex `path-to-regexp` expressions are
200
+ not supported and produce a warning ([07-configuration.md](./07-configuration.md)).
201
+
202
+ Move your `NEXT_PUBLIC_*` variables into the `clientEnv` list and simplify their
203
+ names (the prefix no longer carries meaning).
204
+
205
+ ### 3. Move the data layer (the easiest step)
206
+
207
+ The API client and data functions under `lib/` usually do not depend on React;
208
+ they are copied as-is. Make two changes:
209
+
210
+ - Use `import { cache } from "jskelet"` instead of React's `cache()`.
211
+ - Call `reportUpstreamFailure({ status, path })` on failed upstream responses.
212
+ This prevents pages produced with missing data from being written to the cache
213
+ ([06-caching.md](./06-caching.md)).
214
+
215
+ ### 4. Set up the layout (half a day)
216
+
217
+ Translate `app/layout.jsx` into `views/layout.ejs`. Copying the framework's
218
+ default layout (`node_modules/jskelet/src/templates/layout.ejs`) and editing it
219
+ is the fastest path.
220
+
221
+ If you fetch data inside `layout.jsx` (navigation, site settings), move it into
222
+ `hooks.layoutContext()`: it runs in parallel with the body render, and every
223
+ field it returns becomes a layout local.
224
+
225
+ Put your global metadata defaults (`titleTemplate`, `siteUrl`, `description`)
226
+ into `hooks.metadata()`.
227
+
228
+ ### 5. Translate the components (the longest step)
229
+
230
+ Every React component turns into a function:
231
+
232
+ ```jsx
233
+ // Before
234
+ export function Badge({ label, tone = "neutral", className }) {
235
+ return <span className={cn("rounded px-2 py-1", TONES[tone], className)}>{label}</span>;
236
+ }
237
+ ```
238
+
239
+ ```js
240
+ // After — views/components/badge.js
241
+ import { attrs, cn, esc } from "jskelet/html";
242
+
243
+ export function badge({ label, tone = "neutral", class: className }) {
244
+ return `<span${attrs({ class: cn("rounded px-2 py-1", TONES[tone], className) })}>${esc(label)}</span>`;
245
+ }
246
+ ```
247
+
248
+ Things to watch out for:
249
+
250
+ - **Escaping is now on you.** JSX escaped automatically; here you must call
251
+ `esc()` when printing external data.
252
+ - **`className` → `class`.** Since `class` is a reserved word in JS, rename it in
253
+ the props as `class: className`.
254
+ - **An `html` field instead of children.** Nested content is passed as a string.
255
+ - Every function you place under `views/components/**` as a named export can be
256
+ used in templates without importing it
257
+ ([04-rendering.md](./04-rendering.md)).
258
+
259
+ Keep components small and pure; leave data fetching in the controller.
260
+
261
+ ### 6. Migrate the pages (hours per page)
262
+
263
+ Every `page.jsx` splits into a controller plus an EJS template. File them with
264
+ the order in mind:
265
+
266
+ ```
267
+ routes/
268
+ ├── 00-health.mjs health check
269
+ ├── 10-pages.mjs static paths: /, /about
270
+ ├── 50-news.mjs /news/:slug
271
+ └── 99-catch-all.mjs /:slug (if any, last of all)
272
+ ```
273
+
274
+ The places where you used `generateStaticParams()` turn into
275
+ `hooks.prewarmPaths()`. If you have a function that produces the sitemap, use
276
+ the same one.
277
+
278
+ Move your `export const revalidate` values either into `route()`'s second
279
+ argument or, to manage them from a single place, into `cache().html` patterns.
280
+
281
+ ### 7. Turn client components into islands (hours per page)
282
+
283
+ Every `"use client"` component becomes an island. The process:
284
+
285
+ 1. Move the component's **static** output into the server template. Everything
286
+ visible on first render must be in the HTML.
287
+ 2. Write the remaining behavior inside `mount(element, props)`: `useState`
288
+ becomes a local variable, `useEffect` a direct call, event handlers
289
+ `on()`/`onClick()`.
290
+ 3. Pass props as JSON with `data-island-props`.
291
+ 4. Add it to the `registerAll()` map in the entry.
292
+ 5. Choose the binding strategy: the default (visibility), `data-island-eager`
293
+ (global behavior) or `data-island-idle` (heavy and non-critical).
294
+
295
+ For components using Context, `createStore()` is the closest equivalent
296
+ ([05-islands.md](./05-islands.md)).
297
+
298
+ **This is where the biggest win of this step lies:** the hydrated area is not
299
+ the whole page, only the parts that are genuinely interactive.
300
+
301
+ ### 8. Move the CSS (1-2 hours)
302
+
303
+ If your Tailwind configuration is already in v4 format, `styles/globals.css`
304
+ stays almost the same. The one critical addition is the `@source` directives:
305
+
306
+ ```css
307
+ @import "tailwindcss" source(none);
308
+
309
+ @source "../views";
310
+ @source "../client";
311
+ @source "../routes";
312
+ @source "../lib";
313
+ ```
314
+
315
+ Without these, the classes used in templates (especially variants like
316
+ `data-[state=open]:…`) are silently dropped
317
+ ([08-build.md](./08-build.md)).
318
+
319
+ If you use `next/font`, add `fonts: [{ family, weights }]` and write the
320
+ `@font-face` blocks by hand; the generated files sit under `public/fonts/` with
321
+ stable names.
322
+
323
+ ### 9. Verify and measure
324
+
325
+ - Browse with `jskelet dev` and confirm there are no errors in the dev overlay.
326
+ - On the report page (`/__jskelet/dev/report`), look at each page's Web Vitals
327
+ measurements, SSR size and island status ([09-dev-tools.md](./09-dev-tools.md)).
328
+ - Clear the missing-icon warnings.
329
+ - Compare the sizes in the `jskelet build` output with your old Next bundle.
330
+ - Check that the `X-JSkelet-Cache` header returns `HIT` on the pages you expect.
331
+
332
+ ### 10. Go live
333
+
334
+ Go through the checklist in [10-deployment.md](./10-deployment.md). Keeping the
335
+ old Next setup alongside for a while and shifting traffic gradually is useful,
336
+ especially for verifying that the redirect rules are correct.
337
+
338
+ ## Common mistakes during migration
339
+
340
+ - **Forgetting `esc()`.** Writing `${value}` out of JSX habit means XSS. In
341
+ templates, mind the distinction between `<%= %>` (escaped) and `<%- %>` (raw).
342
+ - **Opening a new directory without adding `@source`.** The classes are silently
343
+ dropped.
344
+ - **Putting the catch-all route in the wrong order.** `/:slug` always goes last.
345
+ - **Making the whole page an island.** The win comes from the server HTML being
346
+ complete; bind the island only to the genuinely interactive part.
347
+ - **Forgetting to pass `revalidate`.** The cache stays off, every request is
348
+ rendered, and `X-JSkelet-Cache: MISS` is returned.
349
+ - **Not calling `reportUpstreamFailure()`.** When the upstream goes down, the
350
+ page produced with missing data is served for the entire TTL.
351
+ - **Putting a secret key in `clientEnv`.** The values sit in the bundle as plain
352
+ text.
353
+
354
+ ## What's next
355
+
356
+ - The reasons behind the architectural decisions:
357
+ [02-architecture.md](./02-architecture.md)
358
+ - Details of the island model: [05-islands.md](./05-islands.md)
359
+ - Configuration reference: [07-configuration.md](./07-configuration.md)