jskelet 0.1.1 → 0.1.3

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 (66) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +129 -2
  3. package/README.md +21 -7
  4. package/bin/jskelet.mjs +6 -6
  5. package/docs/03-routing.md +48 -9
  6. package/docs/04-render-ve-sablonlar.md +2 -2
  7. package/docs/05-islands.md +59 -6
  8. package/docs/06-cache.md +240 -26
  9. package/docs/07-yapilandirma.md +108 -7
  10. package/docs/08-build.md +4 -4
  11. package/docs/09-dev-araclari.md +5 -0
  12. package/docs/12-panel-ve-oturum.md +384 -0
  13. package/docs/README.md +25 -2
  14. package/docs/en/01-getting-started.md +292 -0
  15. package/docs/en/02-architecture.md +305 -0
  16. package/docs/en/03-routing.md +493 -0
  17. package/docs/en/04-rendering.md +504 -0
  18. package/docs/en/05-islands.md +492 -0
  19. package/docs/en/06-caching.md +640 -0
  20. package/docs/en/07-configuration.md +789 -0
  21. package/docs/en/08-build.md +383 -0
  22. package/docs/en/09-dev-tools.md +314 -0
  23. package/docs/en/10-deployment.md +332 -0
  24. package/docs/en/11-migration.md +360 -0
  25. package/docs/en/12-dashboards-and-sessions.md +392 -0
  26. package/docs/en/README.md +112 -0
  27. package/package.json +4 -2
  28. package/src/build/build.mjs +1 -1
  29. package/src/build/tasks/client.mjs +2 -2
  30. package/src/build/tasks/fonts.mjs +3 -3
  31. package/src/build/tasks/icons.mjs +1 -1
  32. package/src/build/tasks/images.mjs +2 -2
  33. package/src/client/devtools/overlay.js +196 -164
  34. package/src/client/devtools/report.js +96 -96
  35. package/src/client/form.js +192 -0
  36. package/src/client/index.js +10 -1
  37. package/src/client/registry.js +78 -4
  38. package/src/client/swap.js +188 -0
  39. package/src/config/defaults.js +83 -0
  40. package/src/config/index.js +129 -18
  41. package/src/config/pattern.js +1 -1
  42. package/src/dev-server.mjs +1 -1
  43. package/src/http/control-flow.js +16 -1
  44. package/src/http/cookies.js +257 -0
  45. package/src/http/request-context.js +162 -0
  46. package/src/index.js +26 -2
  47. package/src/init.mjs +32 -31
  48. package/src/log.mjs +8 -2
  49. package/src/logo.png +0 -0
  50. package/src/runtime/alias-hooks.mjs +1 -1
  51. package/src/server/assets.js +1 -1
  52. package/src/server/create-app.js +12 -4
  53. package/src/server/data-cache.js +244 -0
  54. package/src/server/dev/devtools.js +6 -2
  55. package/src/server/dev/report.js +8 -1
  56. package/src/server/dev/version-check.mjs +139 -0
  57. package/src/server/head-hints.js +1 -1
  58. package/src/server/html-cache.js +32 -6
  59. package/src/server/middleware/csrf.js +134 -0
  60. package/src/server/prewarm.js +164 -19
  61. package/src/server/render.js +256 -20
  62. package/src/server/router.js +14 -7
  63. package/src/server/status-page.js +1 -1
  64. package/src/version.mjs +9 -4
  65. package/src/views/components/loader.js +1 -1
  66. package/src/views/helpers/tags.js +53 -1
@@ -0,0 +1,332 @@
1
+ # 10 — Deployment
2
+
3
+ This document explains how to put a JSkelet application into production: the
4
+ prod build and start flow, the environment variables you should set, a working
5
+ Docker setup, reverse proxy and `trust proxy` notes, how a health check endpoint
6
+ is added, and how the cache behaves when you scale out. What the build steps do
7
+ is in [08-build.md](./08-build.md), cache behavior in
8
+ [06-caching.md](./06-caching.md).
9
+
10
+ ## The prod flow
11
+
12
+ ```bash
13
+ npm ci
14
+ npm run build # jskelet build
15
+ npm start # jskelet start
16
+ ```
17
+
18
+ If `NODE_ENV` is not given, `jskelet build` sets it to `production` and runs all
19
+ steps: fonts, icon sprite, CSS, client JS, images, manifest, precompress.
20
+
21
+ `jskelet start` first looks for `.jskelet/manifest.json`; if it is missing, it
22
+ runs the build itself. In a Docker image the build has already happened, so this
23
+ is a no-op; the point is that someone running `npm start` directly does not end
24
+ up with an unstyled page.
25
+
26
+ When the server is ready it prints a single line:
27
+
28
+ ```
29
+ jskelet → http://localhost:3000 (production)
30
+ ```
31
+
32
+ The process is protected by two safety nets: `unhandledRejection` and
33
+ `uncaughtException` are logged and the process stays up. On a news site, an
34
+ error on a single page should not take the whole site down. If you want to hook
35
+ this up to your own error tracking tool (Sentry etc.), you can add your own
36
+ listener to the same events.
37
+
38
+ ## Environment variables
39
+
40
+ No variable is required; all of them have a sensible default. The ones worth
41
+ considering in production:
42
+
43
+ | Variable | Recommendation | Why |
44
+ | --- | --- | --- |
45
+ | `NODE_ENV` | `production` | Template cache, reading the manifest once, throwing on a broken route module |
46
+ | `PORT` | `3000` | The port your orchestrator expects |
47
+ | `HOST` | `0.0.0.0` | For external access inside a container (the default) |
48
+ | `PREWARM_MAX` | Depends on site size | Number of pages warmed at startup |
49
+ | `PREWARM_INTERVAL_SECONDS` | `0` or a long value | If you want to keep never-visited pages warm |
50
+ | `DEV_TOKEN` | Staging only | Hides an environment that is not public yet |
51
+
52
+ The full list and the precedence order of the prewarm settings:
53
+ [07-configuration.md](./07-configuration.md).
54
+
55
+ Since the CLI runs with `--env-file-if-exists=.env`, a `.env` file is loaded
56
+ automatically if it exists; if not, no error is raised. In a container,
57
+ environment variables are usually injected directly instead of using this file.
58
+ Using both sources together blurs which value actually applies; not shipping a
59
+ `.env` in the prod image is the cleanest option.
60
+
61
+ **Secret keys must not go into the `clientEnv` list:** those values are embedded
62
+ into the client bundle as plain text ([08-build.md](./08-build.md)).
63
+
64
+ ## Docker
65
+
66
+ A multi-stage image: the build stage compiles with dev dependencies, the runtime
67
+ stage carries only production dependencies and the build output.
68
+
69
+ ```dockerfile
70
+ # syntax=docker/dockerfile:1
71
+
72
+ # ---------- build ----------
73
+ FROM node:22-bookworm-slim AS build
74
+ WORKDIR /app
75
+
76
+ # Dependencies in a separate layer: don't reinstall when sources change.
77
+ COPY package.json package-lock.json ./
78
+ RUN npm ci
79
+
80
+ # `public/fonts/` must be committed: the build should not need network access.
81
+ COPY . .
82
+
83
+ ENV NODE_ENV=production
84
+ RUN npx jskelet build
85
+
86
+ # ---------- runtime ----------
87
+ FROM node:22-bookworm-slim AS runtime
88
+ WORKDIR /app
89
+
90
+ ENV NODE_ENV=production
91
+ ENV PORT=3000
92
+ ENV HOST=0.0.0.0
93
+
94
+ COPY package.json package-lock.json ./
95
+ # sharp and tailwind are only needed at build time; keep them out of the runtime image.
96
+ RUN npm ci --omit=dev && npm cache clean --force
97
+
98
+ COPY --from=build /app/jskelet.config.mjs ./jskelet.config.mjs
99
+ COPY --from=build /app/jsconfig.json ./jsconfig.json
100
+ COPY --from=build /app/routes ./routes
101
+ COPY --from=build /app/views ./views
102
+ COPY --from=build /app/lib ./lib
103
+ COPY --from=build /app/public ./public
104
+ COPY --from=build /app/.jskelet ./.jskelet
105
+
106
+ # Non-root user.
107
+ USER node
108
+
109
+ EXPOSE 3000
110
+
111
+ # Health check: assumes you added the route below.
112
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
113
+ CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/healthcheck').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
114
+
115
+ CMD ["npx", "jskelet", "start"]
116
+ ```
117
+
118
+ Notes:
119
+
120
+ - **`client/` and `styles/` are not needed in the runtime image:** their output
121
+ is under `public/assets/`. `views/` and `routes/` are needed, because
122
+ rendering happens at runtime. Copy `lib/` only if your project has one.
123
+ - **`.jskelet/` is needed:** without `manifest.json`, `asset()` cannot find the
124
+ hashed URLs and `jskelet start` will try to run the build from scratch.
125
+ - **`sharp` is not needed in the runtime image:** it is only for build-time image
126
+ optimization. `--omit=dev` leaves it out (if it was installed as a
127
+ devDependency).
128
+ - If you would rather call `jskelet start` without `npx`,
129
+ `CMD ["node", "node_modules/jskelet/bin/jskelet.mjs", "start"]` works too.
130
+
131
+ `.dockerignore`:
132
+
133
+ ```
134
+ node_modules
135
+ .git
136
+ .jskelet
137
+ public/assets
138
+ .env
139
+ ```
140
+
141
+ The build stage produces these itself with `npx jskelet build`.
142
+
143
+ ### Deploying from a subdirectory of the repo
144
+
145
+ The examples in this repo pull jskelet with `"jskelet": "file:../.."` rather
146
+ than from npm. In tools like Coolify, Railway or Render, if you set the "base
147
+ directory" to `examples/marketing`, the build context becomes only that
148
+ directory, `../..` falls outside the context, and installation fails at
149
+ `npm ci`. The correct setting: **base directory `/`**, Dockerfile location
150
+ `/examples/marketing/Dockerfile`. The working example is in
151
+ `examples/marketing/Dockerfile` and assumes the repo root as its context:
152
+
153
+ ```bash
154
+ docker build -f examples/marketing/Dockerfile -t jskelet-marketing .
155
+ docker run --rm -p 3000:3000 -e SITE_URL=https://example.com jskelet-marketing
156
+ ```
157
+
158
+ In your own application jskelet will be an ordinary dependency, so this
159
+ constraint does not apply; the multi-stage image above is enough.
160
+
161
+ ## Health check
162
+
163
+ The framework does **not** add a ready-made health check endpoint; you have to
164
+ put it in your own route. Since the default `devGateBypass` list contains
165
+ `/api/healthcheck`, using that name is the least surprising option: it stays
166
+ reachable even in an environment with `DEV_TOKEN` set.
167
+
168
+ ```js
169
+ // routes/00-health.mjs
170
+ import { getHtmlCacheSize } from "jskelet";
171
+
172
+ export default function register(app) {
173
+ app.get("/api/healthcheck", (req, res) => {
174
+ res.setHeader("Cache-Control", "no-store");
175
+ res.json({
176
+ ok: true,
177
+ uptime: process.uptime(),
178
+ cache: getHtmlCacheSize(),
179
+ });
180
+ });
181
+ }
182
+ ```
183
+
184
+ The `00-` prefix in the file name makes sure this route is registered before any
185
+ catch-all ([03-routing.md](./03-routing.md)).
186
+
187
+ If you are going to use a different path, update the `devGateBypass` list,
188
+ otherwise your orchestrator will see a 404 on staging:
189
+
190
+ ```js
191
+ devGateBypass: ["/healthz", "/robots.txt", "/sitemap.xml", "/favicon.ico"]
192
+ ```
193
+
194
+ The warming round does not affect the health check: even if prewarm fails, the
195
+ process stays up and pages are served (cold, but served).
196
+
197
+ If you need to separate readiness from liveness, you can report the warming
198
+ status too:
199
+
200
+ ```js
201
+ import { prewarmProgress } from "jskelet";
202
+
203
+ app.get("/api/ready", (req, res) => {
204
+ const warmedUp = !prewarmProgress.active && prewarmProgress.finishedAt !== null;
205
+ res.status(warmedUp ? 200 : 503).json({ warmedUp, ...prewarmProgress });
206
+ });
207
+ ```
208
+
209
+ Remember to exclude this endpoint's path from warming with `prewarmSkip` (the
210
+ default `/api/` prefix already covers it).
211
+
212
+ ## Reverse proxy
213
+
214
+ The Express application sets `trust proxy` to **on**
215
+ (`app.set("trust proxy", true)`). The consequences:
216
+
217
+ - `req.protocol` is read from the `X-Forwarded-Proto` header, so if the proxy
218
+ terminates TLS, `https` is returned correctly.
219
+ - `req.ip` is resolved from the `X-Forwarded-For` chain.
220
+ - Absolute URLs produced by `res.redirect()` carry the correct scheme.
221
+
222
+ This setting **assumes the proxy writes these headers reliably.** If you are
223
+ going to expose the application directly to the internet, remember that a client
224
+ can fabricate `X-Forwarded-*` headers; always run behind a proxy or load
225
+ balancer and make sure the proxy overwrites the incoming `X-Forwarded-For`
226
+ header.
227
+
228
+ An example nginx configuration:
229
+
230
+ ```nginx
231
+ upstream jskelet {
232
+ server 127.0.0.1:3000;
233
+ keepalive 32;
234
+ }
235
+
236
+ server {
237
+ listen 443 ssl http2;
238
+ server_name example.com;
239
+
240
+ # Response bodies already arrive compressed; don't compress a second time.
241
+ gzip off;
242
+
243
+ location / {
244
+ proxy_pass http://jskelet;
245
+ proxy_http_version 1.1;
246
+
247
+ proxy_set_header Host $host;
248
+ proxy_set_header X-Real-IP $remote_addr;
249
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
250
+ proxy_set_header X-Forwarded-Proto $scheme;
251
+ proxy_set_header Connection "";
252
+
253
+ # Forward it to the upstream so we can get a compressed response.
254
+ proxy_set_header Accept-Encoding $http_accept_encoding;
255
+ }
256
+ }
257
+ ```
258
+
259
+ Key points:
260
+
261
+ - **Do not compress twice.** JSkelet does the brotli/gzip negotiation itself and
262
+ stores the compressed body for cached pages. Leaving nginx's own `gzip` on can
263
+ lead to decompressing brotli and re-gzipping it.
264
+ - **Forward `Accept-Encoding`**, otherwise the application will not compress and
265
+ the ready-made compressed bodies in the cache go unused.
266
+ - `Vary: Accept-Encoding` is written by the application; proxy caches take it
267
+ into account.
268
+
269
+ ### Together with a CDN
270
+
271
+ The header written on cacheable pages:
272
+
273
+ ```
274
+ Cache-Control: public, max-age=0, s-maxage=<revalidate>, stale-while-revalidate=60
275
+ ```
276
+
277
+ `max-age=0` disables browser storage, `s-maxage` tells the CDN the duration. So
278
+ the same freshness model works across two layers together: the CDN serves its
279
+ own copy for the duration of `s-maxage`, asks the origin when it expires, and
280
+ the origin answers instantly from its own cache.
281
+
282
+ The `X-JSkelet-Cache` header makes it easier to diagnose which layer answered;
283
+ read it together with the CDN's own cache header
284
+ ([06-caching.md](./06-caching.md)).
285
+
286
+ Static assets (`/assets/`, `/fonts/`) are marked `immutable`, so they can be
287
+ held indefinitely on the CDN; when the hash changes, so does the URL.
288
+
289
+ ## Scaling
290
+
291
+ The HTML cache lives **in process memory**. When you run more than one replica:
292
+
293
+ - Each replica has its own cache; memory usage is multiplied by the replica
294
+ count (at most 500 entries plus their compressed copies).
295
+ - Each replica runs its own warming round at startup. Set `PREWARM_MAX` and
296
+ `PREWARM_CONCURRENCY` so that your upstream API can handle the load
297
+ multiplied by the replica count.
298
+ - `clearHtmlCache()` only affects the process it is called in. If you need to
299
+ clear all replicas, you have to solve it at the orchestrator level (a restart)
300
+ or with a broadcast mechanism you write yourself.
301
+ - If there is a CDN in front, most requests never reach the origin and the
302
+ per-replica cache difference becomes invisible.
303
+
304
+ To increase the capacity of a single replica, raising the `revalidate`
305
+ durations is usually more effective than adding replicas: as the cache hit rate
306
+ goes up, the work per request drops to almost zero.
307
+
308
+ ## Pre-release checklist
309
+
310
+ - [ ] `NODE_ENV=production`
311
+ - [ ] `npm run build` ran and produced `.jskelet/manifest.json`
312
+ - [ ] The woff2 files under `public/fonts/` are committed
313
+ ([08-build.md](./08-build.md))
314
+ - [ ] The `@source` directives in `styles/globals.css` cover all template
315
+ directories
316
+ - [ ] `hooks.notFound()` is defined and there is a 404 template
317
+ - [ ] `hooks.metadata()` contains `siteUrl` (so relative `canonical`s become
318
+ absolute)
319
+ - [ ] The `cache().html` patterns match the site's freshness profile
320
+ - [ ] `hooks.prewarmPaths()` puts the most important pages first
321
+ - [ ] CSP and security headers are defined in `headers()`
322
+ - [ ] A health check endpoint exists and is in the `devGateBypass` list
323
+ - [ ] `DEV_TOKEN` is set on staging and **not set** in production
324
+ - [ ] The reverse proxy forwards `Accept-Encoding` and does not do its own
325
+ compression
326
+ - [ ] There are no secret keys in the `clientEnv` list
327
+
328
+ ## What's next
329
+
330
+ - Cache settings and prewarm: [06-caching.md](./06-caching.md)
331
+ - All environment variables: [07-configuration.md](./07-configuration.md)
332
+ - Migrating from Next.js: [11-migration.md](./11-migration.md)
@@ -0,0 +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()` | `clearHtmlCache()` | There is currently no per-key invalidation |
57
+ | `cookies()`, `headers()` | `ctx.req.headers`, `ctx.req.cookies`* | Direct access to the Express object |
58
+ | `dynamic = "force-dynamic"` | Not passing `revalidate` | Which means the cache is off |
59
+
60
+ \* Express 5 does not parse cookies on its own; add `cookie-parser` or read the
61
+ header manually.
62
+
63
+ ### Components and helpers
64
+
65
+ | Next.js | JSkelet | Note |
66
+ | --- | --- | --- |
67
+ | `next/link` | `link({ href, text })` — `jskelet/tags` | `title` automatic, `rel`/`target` automatic for external links |
68
+ | `next/link` prefetching | `navigation: { prefetch, prerender }` | Speculation Rules; no client runtime ([07](./07-configuration.md)) |
69
+ | `next/image` | `image({ src, alt, priority })` — `jskelet/tags` | `srcset` from the build manifest |
70
+ | `next/font/google` | `fonts: [{ family, weights }]` | Self-hosted woff2, committed |
71
+ | `@phosphor-icons/react` | `icon({ name, weight })` — `jskelet/tags` | Build-time SVG sprite |
72
+ | `react-dom` preconnect/preload | `preconnect: [...]` + `headHints()` | ([04](./04-rendering.md)) |
73
+ | `clsx` | `cx()` — `jskelet/html` | — |
74
+ | `cn()` (clsx + tailwind-merge) | `cn()` — `jskelet/html` | Same behavior |
75
+ | JSX automatic escaping | `esc()` — `jskelet/html` | **You have to call it yourself** |
76
+ | React Context | `createStore()` — `jskelet/client` | Minimal pub/sub |
77
+ | `useState` / `useEffect` | Plain JS inside the island's `mount()` | — |
78
+ | `useSyncExternalStore` | `store.subscribe()` | — |
79
+ | `<Script>` | A `<script>` in the layout, or an island | — |
80
+
81
+ ### What has no equivalent
82
+
83
+ Account for these from the start in your migration plan:
84
+
85
+ - **React itself.** Components turn into functions that return HTML strings. No
86
+ JSX, no hooks, no virtual DOM.
87
+ - **TypeScript.** The project is plain JS + JSDoc. With `checkJs: true` in
88
+ `jsconfig.json` you get type checking from the editor.
89
+ - **Nested layouts.** There is a single layout; you share common sections with
90
+ EJS `include` or component functions.
91
+ - **Streaming / Suspense / partial prerendering.** The response is produced as a
92
+ single chunk.
93
+ - **Client-side routing.** Navigation is a real page load. Because the server
94
+ HTML comes from the cache it is very fast in practice, but there are no SPA
95
+ transitions. What closes the gap is the `navigation` section:
96
+ prefetch/prerender prepares the document before the click, and
97
+ `viewTransition` smooths the transition
98
+ ([07](./07-configuration.md)).
99
+ - **Server Actions.** Form submissions are ordinary `app.post(...)` handlers.
100
+ - **Per-path invalidation (`revalidatePath`).** For now there is clearing the
101
+ whole cache (`clearHtmlCache()`) or waiting for the TTL to expire.
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)