@nakedev/nextjs-fsd 0.1.0 → 0.1.1

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 (2) hide show
  1. package/README.md +566 -132
  2. package/package.json +5 -4
package/README.md CHANGED
@@ -1,165 +1,599 @@
1
1
  # @nakedev/nextjs-fsd
2
2
 
3
- Keep a Next.js App Router project on [Feature-Sliced Design](https://feature-sliced.design/).
3
+ @nakedev/nextjs-fsd is an npm CLI for keeping a Next.js App Router project on
4
+ Feature-Sliced Design: it shapes the layer structure once, then generates
5
+ pages, slices and layouts that all have the same shape, plus the two pieces of
6
+ wiring every project ends up rewriting by hand — API error handling and
7
+ authentication.
4
8
 
5
- Next.js already creates the app, so this CLI does not. It shapes what is
6
- inside one: `init` once, then `generate` for slices and `add` for the two
7
- pieces of wiring every project rewrites by hand — API error handling and auth.
9
+ Next.js already creates projects, so this CLI has no `create`. The normal
10
+ workflow is: `create-next-app`, then `nextjs-fsd init` once, then run the same
11
+ CLI from the project whenever the frontend grows.
8
12
 
9
- Sibling to [`go-scaffold`](https://github.com/NakePranob/go-scaffold), which
10
- generates the Go backend these templates are written against.
13
+ Sibling to [@nakedev/go-scaffold](https://www.npmjs.com/package/@nakedev/go-scaffold),
14
+ which generates the Go backend these templates are written against.
15
+
16
+ ## Install with npm
17
+
18
+ Install the CLI globally when you expect to use it repeatedly:
19
+
20
+ ~~~bash
21
+ npm install --global @nakedev/nextjs-fsd
22
+
23
+ nextjs-fsd --version
24
+ nextjs-fsd --help
25
+ ~~~
26
+
27
+ npx and bunx are also supported when you do not want a global installation:
28
+
29
+ ~~~bash
30
+ npx @nakedev/nextjs-fsd init
31
+ bunx @nakedev/nextjs-fsd generate page dashboard
32
+ ~~~
33
+
34
+ ### Requirements
35
+
36
+ - Node.js >=20.9 to run the CLI. npm and npx are included with Node.js.
37
+ - A Next.js 15 or 16 **App Router** project in TypeScript, created by
38
+ `create-next-app`. Both layouts it produces are supported as they are:
39
+ `app/` at the root, and `src/app/` when "use src directory" was chosen. FSD's
40
+ own app layer is `src/_app`, so it never collides with either.
41
+ - The project's package manager — npm, pnpm, yarn or bun. It is detected from
42
+ the lockfile, and the CLI installs the dependencies its templates need.
43
+ - An HTTP API for `add auth` and `add error-handling` to talk to. Both are
44
+ written against the shape go-scaffold produces; see their sections below for
45
+ what to change if yours differs.
46
+
47
+ The legacy `pages/` router is not supported.
48
+
49
+ ## Quick start
50
+
51
+ ~~~bash
52
+ npm install --global @nakedev/nextjs-fsd
53
+ npx create-next-app@latest my-app --ts --app --tailwind --eslint
11
54
 
12
- ```bash
13
- bunx create-next-app@latest my-app --ts --app --tailwind
14
55
  cd my-app
56
+ nextjs-fsd init # once: FSD layers, both linters, docs
57
+ nextjs-fsd add auth # shared/auth + a login page (pulls in error handling)
58
+ nextjs-fsd generate page dashboard --auth
59
+ ~~~
60
+
61
+ That gives a project with `/login` and `/dashboard`, a session guard, and an
62
+ API client that normalises every failure into one error type. Then:
63
+
64
+ ~~~bash
65
+ npm run dev
66
+ npm run lint # eslint (import boundary) + steiger (whole tree)
67
+ ~~~
68
+
69
+ `init` creates no empty layer directories. `features/` and `entities/` appear
70
+ when a slice actually needs them, which is FSD's own advice rather than a
71
+ shortcut.
72
+
73
+ ## How the wizard works
74
+
75
+ ### Start with the top-level wizard
76
+
77
+ The command name is the thing people forget, so running the CLI bare asks what
78
+ to do and then delegates:
15
79
 
16
- bunx @nakedev/nextjs-fsd init
17
- bunx @nakedev/nextjs-fsd add auth
18
- bunx @nakedev/nextjs-fsd generate page dashboard --auth
19
- ```
80
+ ~~~bash
81
+ nextjs-fsd # menu: generate / add / show config
82
+ nextjs-fsd generate # menu: page / slice / layout
83
+ nextjs-fsd add # menu: error handling / auth
84
+ ~~~
20
85
 
21
- ## Commands
86
+ Outside an initialised project only `init` can run, and the bare command says
87
+ so instead of offering choices that would fail.
22
88
 
23
- | Command | What it does |
89
+ ### Answer only what is missing
90
+
91
+ Every command prompts for what you leave out and takes what you pass as final.
92
+ `nextjs-fsd generate page` asks for the name; `nextjs-fsd generate page
93
+ dashboard --auth` asks for nothing it already knows.
94
+
95
+ Prompts also disappear when they cannot apply. The "client leaf behind a
96
+ session guard" choice is disabled until `add auth` has run, and the error
97
+ catalog question is not asked at all without `add error-handling` — the menu
98
+ says which prerequisite is missing rather than letting you walk three steps to
99
+ reach an error.
100
+
101
+ ### Non-interactive usage
102
+
103
+ `--defaults` answers every remaining question, which is what CI and scripts
104
+ should pass:
105
+
106
+ ~~~bash
107
+ nextjs-fsd init --locale en --defaults
108
+ nextjs-fsd add auth -y
109
+ nextjs-fsd generate page dashboard --auth --route "(admin)/dashboard" --defaults
110
+ nextjs-fsd generate slice features checkout --segments ui,model --defaults
111
+ ~~~
112
+
113
+ With no TTY, a command that still needs an answer exits 1 without writing
114
+ anything, and names every missing flag at once instead of failing on the first.
115
+
116
+ ## Command overview
117
+
118
+ | Command | Purpose | Alias |
119
+ |---|---|---|
120
+ | init | Shape an existing App Router project into FSD layers | — |
121
+ | generate | Open the page/slice/layout wizard | g |
122
+ | generate page [name] | Add a `_pages` slice and its route file | g p |
123
+ | generate slice [layer] [name] | Add a features/entities/widgets slice | g s |
124
+ | generate layout [name] | Add shared chrome for a group of routes | g l |
125
+ | add | Open the infrastructure wizard | — |
126
+ | add error-handling | Add `shared/api`: error type, catalogs, client | add errors |
127
+ | add auth | Add `shared/auth` and a login page | — |
128
+ | config show | Print the resolved project configuration | — |
129
+ | config set locale \<th\|en\> | Change the language of future generated copy | — |
130
+
131
+ Every command except `init` expects to run from an initialised project, which
132
+ is any directory with a `nextjs-fsd.config.json` at its root.
133
+
134
+ ## init — shape a project into FSD layers
135
+
136
+ ~~~bash
137
+ nextjs-fsd init # interactive: asks for the copy language
138
+ nextjs-fsd init --defaults # Thai copy, no confirmation
139
+ nextjs-fsd init --locale en --defaults
140
+ nextjs-fsd init --no-install # write files, install later
141
+ ~~~
142
+
143
+ Run this once, in a project `create-next-app` already made. A second run
144
+ refuses rather than re-writing, and points at `generate` and `add`.
145
+
146
+ ### Options
147
+
148
+ | Option | Effect |
24
149
  |---|---|
25
- | `nextjs-fsd` | menu asks what to do, then delegates |
26
- | `init` | FSD layers in `src/`, the `@/*` alias, steiger + its config, `docs/fsd.md` |
27
- | `generate page [name]` | a `_pages` slice plus the thin `app/<route>/page.tsx` that re-exports it |
28
- | `generate slice [layer] [name]` | a `features`/`entities`/`widgets` slice with only the segments it needs |
29
- | `generate layout [name]` | a shared route shell in `_app/layouts` plus the `layout.tsx` that re-exports it |
30
- | `add error-handling` | `shared/api` `ApiError`, per-domain error catalogs, axios client, `QueryClient` |
31
- | `add auth` | `shared/auth` — in-memory access token, session hooks, route guard, login page |
32
- | `config show` | the resolved config and which features are installed |
33
- | `config set locale <th\|en>` | the language future generated copy is written in |
34
-
35
- Every command asks for what you leave out. `--defaults` answers every
36
- question, for CI.
37
-
38
- **Nothing is ever overwritten.** Re-running a generate command on something
39
- that exists *extends* it — `--segments ui,model` on a ui-only slice writes
40
- `model/` and appends its exports to `index.ts`, leaving every existing file
41
- alone. If there is nothing new to add, it says so rather than writing. Adding
42
- a segment later is the normal path, which is the whole point of "a slice gets
43
- only the segments it has code for".
44
-
45
- It also will not add a second route file for a page that is already routed
46
- from somewhere else — two `page.tsx` resolving to the same URL is a Next.js
47
- build error, and a page generated with `--route "(admin)/dashboard"` is not
48
- where the default would look.
49
-
50
- ## What `init` does to an existing project
51
-
52
- - moves `app/globals.css` to `src/_app/styles/globals.css` and names the
53
- `@source` trees Tailwind now has to scan, then repoints the import in
54
- `layout.tsx`
55
- - puts `./src/*` first in the tsconfig `@/*` alias, keeping whatever was there
56
- as a fallback so existing `@/…` imports keep resolving
57
- - writes `eslint.fsd.mjs` and spreads it into `eslint.config.mjs`, so a
58
- wrong-way or slice-internal import is flagged per file in the editor — the
59
- core `no-restricted-imports` rule, no new dependency
60
- - adds `steiger` and `steiger.config.ts` (with the `_app`/`_pages` layer-name
61
- rule turned off) and chains both into the `lint` script
62
- - writes `components.json` so `shadcn add <name>` lands in `src/shared/ui`
63
- and its `utils.ts` in `src/shared/lib` `shadcn init`'s own defaults put
64
- them in `./components/ui` and the project root, outside the layers entirely
65
- - writes `docs/fsd.md`, a `.claude/skills/nextjs-fsd/SKILL.md` skill, and
66
- appends a section to `AGENTS.md` pointing at both — so an agent asked to "add
67
- a settings screen" reaches for the generator instead of hand-writing the
68
- files the linters then report
69
-
70
- It creates no empty layer directories — `features/` and `entities/` appear
71
- when a slice actually needs them, which is the FSD advice, not a shortcut.
150
+ | --locale \<th\|en\> | Language for generated user-facing copy; Thai is the default |
151
+ | --no-install | Write everything but do not run the package manager |
152
+ | --defaults | Skip every question, including the confirmation summary |
153
+ | -y, --yes | Skip only the confirmation summary |
154
+
155
+ ### What init changes in your project
156
+
157
+ - moves `app/globals.css` to `src/_app/styles/globals.css`, names the trees
158
+ Tailwind now has to scan with `@source`, and repoints the import in
159
+ `layout.tsx`. Moving it out of the route directory is what takes it out of
160
+ Tailwind's auto-detection, which is why the `@source` lines are not optional
161
+ - puts `./src/*` **in front of** whatever the tsconfig `@/*` alias mapped to,
162
+ so `@/_pages/login` resolves while any existing `@/…` import keeps working
163
+ - spreads the generated ESLint rules into `eslint.config.mjs` and appends
164
+ `steiger ./src` to the `lint` script
165
+
166
+ ### What init writes
167
+
168
+ ~~~text
169
+ src/_app/styles/globals.css # moved, with @source lines added
170
+ eslint.fsd.mjs # the import boundary as ESLint rules
171
+ steiger.config.ts # the whole-tree FSD checks
172
+ components.json # aims `shadcn add` at src/shared/ui
173
+ docs/fsd.md # the convention, in full
174
+ .claude/skills/nextjs-fsd/SKILL.md # the same contract, for Claude Code
175
+ AGENTS.md # an FSD section appended, or created
176
+ CLAUDE.md # created if absent, includes AGENTS.md
177
+ nextjs-fsd.config.json # layers, appDir, alias, locale, features
178
+ ~~~
179
+
180
+ `components.json` is written **before** anyone runs `shadcn init`, because
181
+ shadcn's own defaults put components in `./components/ui` and a `utils.ts` at
182
+ the project root outside the layers entirely. An existing `components.json`
183
+ is left alone.
184
+
185
+ `nextjs-fsd.config.json` records where the App Router lives, the import alias,
186
+ the copy language, and which features are installed, so later commands
187
+ continue from the same choices. Missing feature keys are filled in by looking
188
+ at the tree rather than assumed false.
189
+
190
+ ## generate page [name] add a route
191
+
192
+ ~~~bash
193
+ nextjs-fsd generate page settings
194
+ nextjs-fsd generate page dashboard --auth
195
+ nextjs-fsd generate page dashboard --route "(admin)/dashboard" --errors
196
+ nextjs-fsd generate page loans --route "loans/[id]" --client
197
+ nextjs-fsd g p settings --defaults
198
+ ~~~
199
+
200
+ ### Options
201
+
202
+ | Option | Effect |
203
+ |---|---|
204
+ | --title \<title\> | Heading and browser title; defaults to the Title Case of the name |
205
+ | --route \<path\> | App Router path; defaults to the page name |
206
+ | --no-route | Write the slice only, no route file |
207
+ | --client | Also create a `"use client"` leaf component |
208
+ | --auth | The client leaf sits behind `useRequireSession` (needs `add auth`) |
209
+ | --errors | Add this page's own error catalog (needs `add error-handling`) |
210
+ | --defaults | Skip every question: server component only, route = the page name |
72
211
 
73
- ## Two linters, on purpose
212
+ `--route` takes the App Router's own shapes: a route group `(admin)`, a
213
+ dynamic segment `[id]`, a catch-all `[...slug]`, or a plain path. Route groups
214
+ contribute nothing to the URL, so `--route "(admin)/dashboard"` serves
215
+ `/dashboard`, and the command prints the real URL rather than the path.
216
+
217
+ ### What it generates
218
+
219
+ ~~~text
220
+ src/_pages/dashboard/index.ts # public API — the only thing app/ imports
221
+ src/_pages/dashboard/ui/dashboard-page.tsx # server component + `metadata`
222
+ src/_pages/dashboard/ui/dashboard-content.tsx # --client / --auth: the "use client" leaf
223
+ src/_pages/dashboard/model/dashboard-errors.ts # --errors: this page's error catalog
224
+ app/(admin)/dashboard/page.tsx # re-exports the page and its metadata
225
+ ~~~
226
+
227
+ The route file re-exports **both** the component and `metadata`. A route file
228
+ that re-exports `default` alone silently drops the page title, with no error
229
+ anywhere.
230
+
231
+ `"use client"` goes on the leaf, never on the page: a page component that
232
+ needs the browser ships its whole tree to it.
233
+
234
+ ## generate slice [layer] [name] — add a features/entities slice
235
+
236
+ ~~~bash
237
+ nextjs-fsd generate slice features checkout --segments ui,model
238
+ nextjs-fsd generate slice entities loan --segments ui,api,lib --errors
239
+ nextjs-fsd g s entities loan --defaults
240
+ ~~~
74
241
 
75
- ESLint and steiger overlap on paper and not in practice:
242
+ Layers are `features`, `entities` and `widgets`. `_pages` slices come from
243
+ `generate page`; `_app` and `shared` are written by `init` and `add`.
76
244
 
77
- | | catches | when |
245
+ FSD v2.1 discourages `widgets/` a UI block carries user-flow logic, which
246
+ makes the widget/feature boundary arbitrary — so reach for `features/` first.
247
+
248
+ ### Options
249
+
250
+ | Option | Effect |
251
+ |---|---|
252
+ | --segments \<list\> | Comma-separated: `ui,model,api,lib`; defaults to `ui` |
253
+ | --errors | Add this slice's own error catalog (needs `add error-handling`) |
254
+ | --defaults | Skip every question: the `ui` segment only |
255
+
256
+ ### Segments
257
+
258
+ A slice gets only the segments it has code for; an empty segment folder is
259
+ noise. `ui/` alone is the common case.
260
+
261
+ | Segment | Holds | Generated shape |
78
262
  |---|---|---|
79
- | ESLint | this import points the wrong way, or reaches past a slice's `index.ts` | as you type, per file |
80
- | steiger | a slice with no references, a layer sliced too finely, a segment named after its type | on demand, whole tree |
263
+ | ui | Components | A component, `"use client"` only when it uses the slice's own hook |
264
+ | model | State and hooks | A `use<Name>` hook |
265
+ | api | Requests | A TanStack Query hook, a mutation that invalidates its key, and the record type |
266
+ | lib | Pure helpers | A formatting function |
267
+
268
+ The `api` segment requires `add error-handling`, and says so rather than
269
+ generating a bare `fetch` — which would skip the bearer token, the
270
+ single-flight 401 refresh, and the conversion into `ApiError`.
271
+
272
+ ### What it generates
273
+
274
+ ~~~text
275
+ src/entities/loan/index.ts # public API
276
+ src/entities/loan/ui/loan.tsx # ui
277
+ src/entities/loan/model/loan.ts # model
278
+ src/entities/loan/api/loan.ts # api — LoanRecord, loanKey, useLoanQuery, useCreateLoan
279
+ src/entities/loan/lib/loan.ts # lib
280
+ src/entities/loan/model/loan-errors.ts # --errors
281
+ ~~~
282
+
283
+ The record type is `LoanRecord`, not `Loan`: the `ui` segment already exports
284
+ a `Loan` component, and one `index.ts` cannot re-export two different things
285
+ under one name. A type that cannot be imported through the slice's public API
286
+ is a type nothing can annotate against without breaking the import boundary.
287
+
288
+ ## generate layout [name] — shared chrome for a group of routes
289
+
290
+ ~~~bash
291
+ nextjs-fsd generate layout admin # applies at app/(admin)/
292
+ nextjs-fsd generate layout auth --route "(auth)"
293
+ nextjs-fsd generate layout admin --route reports # reuse it at another path
294
+ nextjs-fsd g l admin --defaults
295
+ ~~~
296
+
297
+ ### Options
298
+
299
+ | Option | Effect |
300
+ |---|---|
301
+ | --route \<path\> | Where it applies; defaults to the route group `(<name>)` |
302
+ | --no-route | Write the component only, no `layout.tsx` |
303
+ | --defaults | Skip every question: route = the `(<name>)` group |
81
304
 
82
- A bad import is visible in one file, so that check belongs where it is
83
- instant. Nothing in one file shows that a slice has no consumers — that needs
84
- the whole tree. `init` sets up both and chains them into `lint`.
305
+ ### What it generates
306
+
307
+ ~~~text
308
+ src/_app/layouts/admin-layout.tsx # the shell component
309
+ src/_app/layouts/index.ts # public API of the layouts segment
310
+ app/(admin)/layout.tsx # re-exports it as default
311
+ ~~~
85
312
 
86
- The generated ESLint block has one trap worth knowing if you edit it: flat
87
- config **replaces** a rule's options when a later block matches the same file
88
- rather than merging them, so all of a layer's `no-restricted-imports` patterns
89
- have to stay in that layer's single block. A test pins this down, because a
90
- split config still reads as though both halves applied.
313
+ Layouts live in `_app`, not `_pages`: a layout is not one route's content, it
314
+ is what several routes have in common, and cross-page composition is the app
315
+ layer's job. The default route is a route group because that is a layout's
316
+ usual reason to exist shared chrome for a set of pages, adding nothing to
317
+ the URL.
91
318
 
92
- ## The two `add` targets
319
+ The component takes a plain `{ children }` rather than `LayoutProps<…>`, since
320
+ Next emits no route-props type for a route group.
93
321
 
94
- `add error-handling` is the pattern rather than a library: one `ApiError` at
95
- the boundary, an axios client whose 401 refresh is **single-flight** (a
96
- backend that rotates refresh tokens logs the user out mid-session otherwise),
97
- and error copy that lives in a per-domain catalog instead of one global map.
98
- `error.message` is never rendered — a backend message is written for a log,
99
- and an unmapped code falls through to a fallback that names the action that
100
- failed.
322
+ ## Re-running a generate command extends it
101
323
 
102
- Give each domain its own catalog with `--errors` on either generate command.
324
+ Nothing is ever overwritten. Re-running a command on something that exists
325
+ writes only what is missing, appends the new exports to the slice's
326
+ `index.ts`, and leaves every existing file exactly as it is:
103
327
 
104
- `add auth` needs that client, so it installs it first if it is missing. It
105
- scaffolds password login only: access token in a module variable (never
106
- `localStorage`), refresh token left to the API's httpOnly cookie,
107
- `useRequireSession` as UX with the real gate still on the server. MFA, OAuth
108
- providers and RBAC are not scaffolded.
328
+ ~~~bash
329
+ nextjs-fsd generate slice features checkout --segments ui # ui/ only
330
+ nextjs-fsd generate slice features checkout --segments ui,model # adds model/
331
+ nextjs-fsd generate page dashboard --errors --defaults # adds the catalog
332
+ ~~~
109
333
 
110
- Both assume the shape `go-scaffold` produces`POST /auth/login`,
111
- `POST /auth/refresh`, `GET /users/me`, and an `{ error: { code, message } }`
112
- envelope. Adjust the paths and the `Session` type if yours differ; they are
113
- one file each.
334
+ Adding a segment later is the normal path, not a rewrite which is the whole
335
+ point of "a slice gets only the segments it has code for". When there is
336
+ nothing new to add, the command says so and writes nothing.
114
337
 
115
- ## Generated copy
338
+ Two consequences worth knowing:
339
+
340
+ - the page component is one of those existing files, so a leaf added later is
341
+ not rendered yet. The command prints the one import line to add
342
+ - a page already routed from elsewhere does not get a second route file. Two
343
+ `page.tsx` resolving to one URL is a Next.js build error, and a page
344
+ generated with `--route "(admin)/dashboard"` is not where the default would
345
+ look
346
+
347
+ ## add error-handling — the API boundary
348
+
349
+ ~~~bash
350
+ nextjs-fsd add error-handling
351
+ nextjs-fsd add errors -y
352
+ nextjs-fsd add error-handling --no-install
353
+ ~~~
354
+
355
+ ### Options
356
+
357
+ | Option | Effect |
358
+ |---|---|
359
+ | --no-install | Write the files but do not run the package manager |
360
+ | -y, --yes | Skip the confirmation summary |
361
+
362
+ ### What it generates
363
+
364
+ ~~~text
365
+ src/shared/api/api-error.ts # ApiError + toApiError: one failure type at the boundary
366
+ src/shared/api/error-catalog.ts # the codes every endpoint can answer with
367
+ src/shared/api/error-resolver.ts # code -> one sentence, from the caller's catalogs
368
+ src/shared/api/client.ts # axios instance: bearer token in, ApiError out
369
+ src/shared/api/query-client.ts # QueryClient; a 4xx is an answer, not a retry
370
+ src/shared/api/client.test.ts # bun projects only: the two silent refresh rules
371
+ src/shared/api/index.ts
372
+ src/shared/auth/access-token.ts # the in-memory token the interceptor reads
373
+ src/shared/config/env.ts # NEXT_PUBLIC_API_URL
374
+ src/shared/ui/form-error.tsx # renders a failure, owning no copy of its own
375
+ src/_app/providers/index.tsx # created if absent, and wired into layout.tsx
376
+ .env.example # NEXT_PUBLIC_API_URL, appended
377
+ ~~~
378
+
379
+ Adds `axios` and `@tanstack/react-query`, plus `@types/bun` and a `test`
380
+ script on a bun project.
381
+
382
+ ### The rules this encodes
383
+
384
+ - **One error type at the boundary.** The response interceptor converts every
385
+ failure to `ApiError` (`code`, `status`, `fieldErrors`), so hooks and
386
+ components branch on a stable machine code instead of axios internals.
387
+ - **The 401 refresh is single-flight.** A backend that rotates the refresh
388
+ token on use would see two concurrent refreshes race, and one would
389
+ invalidate the other's cookie — logging the user out mid-session.
390
+ - **A 401 from `/auth/*` is not refreshed.** It means wrong password, not
391
+ expired token; refreshing would spend the cookie of whoever is already
392
+ signed in on that browser.
393
+ - **Copy lives in a per-domain catalog, never one global map.** The domain that
394
+ raises a code is the only place that knows what it means to a user, and a
395
+ single map becomes a merge-conflict magnet as soon as two features grow at
396
+ once. `generate page`/`generate slice` with `--errors` creates one.
397
+ - **`error.message` is never rendered.** A backend message is written for a
398
+ log and changes without anyone here noticing; an unmapped code falls through
399
+ to a fallback that names the action that failed.
400
+
401
+ Render a failure with `<FormError error={mutation.error} catalogs={…} />`.
402
+
403
+ If your API words its error envelope differently, `api-error.ts` is the only
404
+ file that reads the wire format.
405
+
406
+ ## add auth — password login
407
+
408
+ ~~~bash
409
+ nextjs-fsd add auth
410
+ nextjs-fsd add auth -y --no-install
411
+ ~~~
412
+
413
+ Installs `add error-handling` first if it is missing. That is not a
414
+ prerequisite to satisfy by hand — auth cannot work without the client at all,
415
+ so there is no choice to offer.
416
+
417
+ ### Options
418
+
419
+ | Option | Effect |
420
+ |---|---|
421
+ | --no-install | Write the files but do not run the package manager |
422
+ | -y, --yes | Skip the confirmation summary |
423
+
424
+ ### What it generates
425
+
426
+ ~~~text
427
+ src/shared/auth/session.ts # useSession, useLogin, useLogout
428
+ src/shared/auth/require-session.ts # useRequireSession — UX, not the gate
429
+ src/shared/auth/auth-errors.ts # the auth surface's own catalog
430
+ src/shared/auth/index.ts
431
+ src/_pages/login/index.ts
432
+ src/_pages/login/ui/login-page.tsx # server component
433
+ src/_pages/login/ui/login-form.tsx # "use client" leaf, native HTML validation
434
+ app/login/page.tsx
435
+ ~~~
436
+
437
+ ### What it assumes about your API
438
+
439
+ - `POST /auth/login` answers an access token in the body
440
+ - `POST /auth/refresh` trades an httpOnly cookie for a new access token
441
+ - `POST /auth/logout` revokes the refresh token and clears the cookie
442
+ - `GET /users/me` returns the signed-in user
443
+
444
+ Each is one line in one file if yours differ, and the `Session` type carries a
445
+ TODO for the same reason.
446
+
447
+ The access token lives in a module variable and nowhere else. The API returns
448
+ it in the body and keeps the refresh token in an httpOnly cookie, so there is
449
+ nothing to persist: a reload starts with no token and the first 401 spends the
450
+ cookie on a new one. `localStorage` would only make the token readable by any
451
+ injected script.
452
+
453
+ `useRequireSession` redirects anonymous visitors, but treat it as UX only —
454
+ the API's own middleware is the actual gate and runs on every request no
455
+ matter what the browser rendered. It cannot move into `proxy.ts` (Next 16's
456
+ renamed middleware) either: the refresh cookie belongs to the API's origin, so
457
+ the Next server never sees it.
458
+
459
+ **Password login only.** MFA, OAuth providers and RBAC are not scaffolded.
460
+
461
+ ## config — inspect and change project settings
462
+
463
+ ~~~bash
464
+ nextjs-fsd config show
465
+ nextjs-fsd config set locale en
466
+ ~~~
467
+
468
+ `show` prints the resolved configuration — where the layers and the App Router
469
+ live, the import alias, the copy language, the package manager, and which
470
+ features are installed. It also warns when the CLI version differs from the
471
+ one that scaffolded the project, since the templates may have moved on.
472
+
473
+ `set locale` changes the language of **future** generated copy. It rewrites
474
+ nothing already on disk: the catalogs and titles there are meant to be edited,
475
+ and replacing them would throw away the wording someone chose.
476
+
477
+ ## Generated project structure
478
+
479
+ After `init`, `add auth`, and a few generate commands:
480
+
481
+ ~~~text
482
+ app/ # Next.js App Router — routing only
483
+ ├── layout.tsx # composition root: fonts, <Providers>, global CSS
484
+ ├── login/page.tsx # re-exports an FSD page + its metadata
485
+ └── (admin)/
486
+ ├── layout.tsx # re-exports an FSD layout
487
+ └── dashboard/page.tsx
488
+ src/
489
+ ├── _app/ # FSD app layer
490
+ │ ├── layouts/ # shells shared by a group of routes
491
+ │ ├── providers/ # QueryClientProvider and friends
492
+ │ └── styles/globals.css # Tailwind @theme + @source
493
+ ├── _pages/<page>/ # one slice per route
494
+ │ ├── ui/<page>-page.tsx # server component + metadata
495
+ │ ├── ui/<thing>.tsx # "use client" only on the leaves
496
+ │ ├── model/<page>-errors.ts # this page's error catalog
497
+ │ └── index.ts # public API
498
+ ├── features/<slice>/ # a whole user action, once two pages need it
499
+ ├── entities/<slice>/ # a business object, once two features need it
500
+ └── shared/ # infrastructure only
501
+ ├── api/ # ApiError, catalogs, client, QueryClient
502
+ ├── auth/ # token, session hooks, route guard
503
+ ├── config/ # env
504
+ └── ui/ # primitives, FormError
505
+ ~~~
506
+
507
+ The FSD `app` and `pages` layers are named `_app` and `_pages` because Next.js
508
+ owns those names at the root.
509
+
510
+ Imports point downwards only — `_app → _pages → widgets → features → entities
511
+ → shared` — two slices on the same layer never import each other, and a slice
512
+ is always entered through its `index.ts`.
513
+
514
+ ## Two linters, on purpose
515
+
516
+ `npm run lint` runs both, and they are not redundant:
517
+
518
+ | | Catches | When |
519
+ |---|---|---|
520
+ | ESLint (`eslint.fsd.mjs`) | This import points the wrong way, or reaches past a slice's `index.ts` | As you type, per file, in the editor |
521
+ | steiger (`steiger.config.ts`) | A slice with no references, a layer sliced too finely, a segment named after its type | On demand, whole tree |
522
+
523
+ A bad import is visible in one file, so that check belongs where it is
524
+ instant. Nothing in one file can show that a slice has no consumers.
525
+
526
+ `eslint.fsd.mjs` adds no dependency: the boundary is expressed with the core
527
+ `no-restricted-imports` rule, and the layer order is the whole of it. One trap
528
+ if you edit it — flat config **replaces** a rule's options when a later block
529
+ matches the same file rather than merging them, so all of a layer's patterns
530
+ have to stay in that layer's single block.
531
+
532
+ steiger's `insignificant-slice` is configured as a **warning**. At its default
533
+ severity it fails `lint` on the structure FSD's own guidance recommends
534
+ starting from — a slice extracted for its first consumer — and a fresh slice
535
+ failing CI teaches people to delete the rule rather than the slice. Read the
536
+ message anyway: a slice that stays at one consumer for good probably belongs
537
+ inside it.
538
+
539
+ ## Generated copy and locale
116
540
 
117
541
  `init` asks whether the user-facing strings should be Thai or English
118
- (`--locale th|en`, Thai by default) and records it. It only decides what the
119
- first draft reads like — the catalogs are meant to be edited, and
120
- `config set locale` changes the setting for future generation without
121
- rewriting anything already on disk.
542
+ (`--locale th|en`, Thai by default). It only decides what the first draft reads
543
+ like — the catalogs are meant to be edited.
122
544
 
123
545
  For a Thai project, `generate page` also asks for the page title, because the
124
- Title Case of a kebab name is the right answer in English and the wrong one in
125
- Thai. `--title` skips the question.
546
+ Title Case of a kebab-case name is the right answer in English and the wrong
547
+ one in Thai. `--title` skips the question.
548
+
549
+ Thai copy also needs a Thai face, and `init` says so: `create-next-app` leaves
550
+ `font-family: Arial, Helvetica, sans-serif` on `body`, and none of those faces
551
+ carries Thai, so the browser falls back per glyph. The warning names the
552
+ `next/font` fix. It does not rewrite your font stack — that choice is yours.
126
553
 
127
- Thai copy also needs a Thai face: `init` warns that create-next-app leaves
128
- `font-family: Arial, Helvetica, sans-serif` on `body`, which has no Thai
129
- coverage at all, and names the `next/font` fix. It does not rewrite your font
130
- stack — that choice is yours.
554
+ ## What to edit after generation
131
555
 
132
- ## Requirements
556
+ The CLI gives you a compiling structure and explicit TODOs. Your application
557
+ still owns:
133
558
 
134
- Node 20.9+, a Next.js 15/16 App Router project in TypeScript. Both
135
- create-next-app layouts work as-is (`app/` and `src/app/`) FSD's own app
136
- layer is `src/_app`, so it never collides with either.
559
+ - the record type and request paths in a slice's `api/<name>.ts`
560
+ - the `Session` type and auth endpoint paths in `shared/auth/session.ts`
561
+ - the machine codes and their sentences in every `*-errors.ts` catalog
562
+ - the markup in each page and leaf component
563
+ - the state a `model/` hook actually holds, and the helpers in `lib/`
564
+ - `NEXT_PUBLIC_API_URL` in `.env`, and the API's CORS allowlist on the other
565
+ side — the browser drops the refresh cookie otherwise
137
566
 
138
- The generated `client.test.ts` is written only for bun projects: `bun test`
139
- resolves the `@/` alias with no config, while node and vitest need a runner
140
- set up first, which is not this CLI's business.
567
+ Use the CLI for the repetitive shape, then fill in the TODOs before treating a
568
+ screen as production behaviour.
141
569
 
142
- ## Development
570
+ ## Developing the CLI
143
571
 
144
- ```bash
572
+ ~~~bash
145
573
  pnpm install
146
- pnpm run verify # build + unit tests + smoke test — fast, offline
147
- pnpm run test:integration # real create-next-app + install + next build — slow, networked
148
- ```
149
-
150
- `scripts/smoke-test.mjs` drives the actual binary through
151
- init → add → generate → extend against three fixtures (root `app/`, `src/app/`,
152
- and a bun-shaped one), checks what came out, asserts no `{{…}}` template
153
- syntax leaked, and **type-checks the generated project** by symlinking this
154
- repo's `node_modules` into the fixture. That last step is why `next` and
155
- `react` are devDependencies here; they are never shipped.
156
-
157
- `scripts/integration-test.mjs` is the layer that catches what the smoke test
158
- structurally cannot a dependency range that does not resolve (the fixture
159
- borrows packages it never installed) and Next.js behaviour drift. It costs
160
- minutes and a network, so it runs on demand and in CI.
161
-
162
- The unit tests in `tests/` cover the file patchers in isolation, where the
163
- fiddly cases live.
164
-
165
- MIT.
574
+ pnpm run verify # tsc + unit tests + smoke test — fast, offline
575
+ pnpm run test:integration # real create-next-app + install + next build
576
+ ~~~
577
+
578
+ Three checks, three different failures, none subsuming another:
579
+
580
+ - `tests/patch.test.mjs` covers the patchers that edit files this CLI did not
581
+ write, in isolation, where the fiddly cases live
582
+ - `scripts/smoke-test.mjs` drives the real binary over three fixtures (root
583
+ `app/`, `src/app/`, and a bun-shaped one), asserts no `{{…}}` template syntax
584
+ or CRLF leaked, and **type-checks the generated project** by symlinking this
585
+ repo's `node_modules` into the fixture. That is why `next` and `react` are
586
+ devDependencies here; they are never shipped
587
+ - `scripts/integration-test.mjs` catches what the smoke test structurally
588
+ cannot: a dependency range that does not resolve the fixture borrows
589
+ packages it never installed — and Next.js behaviour drift
590
+
591
+ CI runs `verify` on Ubuntu and Windows, and `test:integration` on Ubuntu.
592
+ Windows is in the matrix because every path this CLI writes is built by hand,
593
+ and a path that becomes a glob has to stay posix.
594
+
595
+ `AGENTS.md` carries the conventions for changing the CLI itself.
596
+
597
+ ## License
598
+
599
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/nextjs-fsd",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Keep a Next.js App Router project on Feature-Sliced Design: init the layout, generate slices, add auth and API error handling",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,11 +21,12 @@
21
21
  "build": "tsc",
22
22
  "dev": "tsc --watch",
23
23
  "test": "node --test tests/*.test.mjs && node scripts/smoke-test.mjs",
24
- "verify": "pnpm run build && pnpm run test",
25
- "prepack": "rm -rf dist && pnpm run build",
26
24
  "test:smoke": "node scripts/smoke-test.mjs",
27
25
  "test:integration": "node scripts/integration-test.mjs",
28
- "prepublishOnly": "pnpm run verify"
26
+ "verify": "pnpm run build && pnpm run test",
27
+ "release:check": "node scripts/check-release.mjs",
28
+ "prepack": "rm -rf dist && pnpm run build",
29
+ "prepublishOnly": "pnpm run release:check && pnpm run verify"
29
30
  },
30
31
  "keywords": [
31
32
  "nextjs",