jskelet 0.2.5 → 0.3.0

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