@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.
- package/README.md +566 -132
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,165 +1,599 @@
|
|
|
1
1
|
# @nakedev/nextjs-fsd
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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 [
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
80
|
-
|
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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)
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
556
|
+
The CLI gives you a compiling structure and explicit TODOs. Your application
|
|
557
|
+
still owns:
|
|
133
558
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
##
|
|
570
|
+
## Developing the CLI
|
|
143
571
|
|
|
144
|
-
|
|
572
|
+
~~~bash
|
|
145
573
|
pnpm install
|
|
146
|
-
pnpm run verify #
|
|
147
|
-
pnpm run test:integration # real create-next-app + install + next build
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
`
|
|
156
|
-
|
|
157
|
-
`
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
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
|
-
"
|
|
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",
|