@nakedev/nextjs-fsd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/bin/nextjs-fsd.js +2 -0
  4. package/dist/commands/add.js +182 -0
  5. package/dist/commands/config.js +53 -0
  6. package/dist/commands/generate.js +403 -0
  7. package/dist/commands/init.js +229 -0
  8. package/dist/index.js +291 -0
  9. package/dist/prompts.js +38 -0
  10. package/dist/types.js +5 -0
  11. package/dist/utils/config.js +86 -0
  12. package/dist/utils/copy.js +87 -0
  13. package/dist/utils/naming.js +76 -0
  14. package/dist/utils/project.js +288 -0
  15. package/dist/utils/render.js +66 -0
  16. package/dist/utils/version.js +23 -0
  17. package/package.json +66 -0
  18. package/templates/add/auth/auth-errors.ts.hbs +22 -0
  19. package/templates/add/auth/index.ts.hbs +4 -0
  20. package/templates/add/auth/login-form.tsx.hbs +48 -0
  21. package/templates/add/auth/login-index.ts.hbs +1 -0
  22. package/templates/add/auth/login-page.tsx.hbs +18 -0
  23. package/templates/add/auth/require-session.ts.hbs +29 -0
  24. package/templates/add/auth/session.ts.hbs +75 -0
  25. package/templates/add/errors/access-token.ts.hbs +19 -0
  26. package/templates/add/errors/api-error.ts.hbs +69 -0
  27. package/templates/add/errors/client.test.ts.hbs +61 -0
  28. package/templates/add/errors/client.ts.hbs +91 -0
  29. package/templates/add/errors/config-index.ts.hbs +1 -0
  30. package/templates/add/errors/env.ts.hbs +3 -0
  31. package/templates/add/errors/error-catalog.ts.hbs +19 -0
  32. package/templates/add/errors/error-resolver.ts.hbs +30 -0
  33. package/templates/add/errors/form-error.tsx.hbs +50 -0
  34. package/templates/add/errors/index.ts.hbs +5 -0
  35. package/templates/add/errors/providers.tsx.hbs +14 -0
  36. package/templates/add/errors/query-client.ts.hbs +25 -0
  37. package/templates/generate/layout/layout.tsx.hbs +20 -0
  38. package/templates/generate/layout/route.tsx.hbs +1 -0
  39. package/templates/generate/page/content.tsx.hbs +17 -0
  40. package/templates/generate/page/errors.ts.hbs +22 -0
  41. package/templates/generate/page/index.ts.hbs +1 -0
  42. package/templates/generate/page/page.tsx.hbs +22 -0
  43. package/templates/generate/page/route.tsx.hbs +3 -0
  44. package/templates/generate/slice/api.ts.hbs +42 -0
  45. package/templates/generate/slice/errors.ts.hbs +22 -0
  46. package/templates/generate/slice/index.ts.hbs +15 -0
  47. package/templates/generate/slice/lib.ts.hbs +4 -0
  48. package/templates/generate/slice/model.ts.hbs +10 -0
  49. package/templates/generate/slice/ui.tsx.hbs +17 -0
  50. package/templates/init/agents-section.md.hbs +25 -0
  51. package/templates/init/claude.md.hbs +1 -0
  52. package/templates/init/components.json.hbs +21 -0
  53. package/templates/init/eslint.fsd.mjs.hbs +91 -0
  54. package/templates/init/fsd.md.hbs +133 -0
  55. package/templates/init/globals.css.hbs +31 -0
  56. package/templates/init/skill.md.hbs +167 -0
  57. package/templates/init/steiger.config.ts.hbs +28 -0
@@ -0,0 +1,69 @@
1
+ import axios from "axios";
2
+
3
+ /**
4
+ * Error envelope the API uses on failure — one `error` object with a stable
5
+ * machine code. Adjust the field names here if your backend words them
6
+ * differently; this is the only place that reads the wire format.
7
+ */
8
+ type ErrorEnvelope = {
9
+ error?: {
10
+ code?: string;
11
+ message?: string;
12
+ details?: unknown;
13
+ request_id?: string;
14
+ };
15
+ };
16
+
17
+ /**
18
+ * Normalised failure for anything crossing the API boundary. Callers branch on
19
+ * `code`/`status`, never on axios internals — that is the whole point of
20
+ * converting here, in the response interceptor, instead of leaking
21
+ * `AxiosError` into hooks and components.
22
+ */
23
+ export class ApiError extends Error {
24
+ constructor(
25
+ readonly code: string,
26
+ message: string,
27
+ readonly status: number,
28
+ readonly details?: unknown,
29
+ readonly requestId?: string,
30
+ ) {
31
+ super(message);
32
+ this.name = "ApiError";
33
+ }
34
+
35
+ /**
36
+ * Per-field messages for a VALIDATION_ERROR. Many backends only send these
37
+ * outside production, so every caller has to cope with this being
38
+ * undefined — never build UI that depends on a field map being present.
39
+ */
40
+ get fieldErrors(): Record<string, string> | undefined {
41
+ if (this.code !== "VALIDATION_ERROR") return undefined;
42
+ const { details } = this;
43
+ if (!details || typeof details !== "object") return undefined;
44
+ const entries = Object.entries(details as Record<string, unknown>).filter(
45
+ ([, value]) => typeof value === "string",
46
+ );
47
+ return entries.length > 0 ? (Object.fromEntries(entries) as Record<string, string>) : undefined;
48
+ }
49
+ }
50
+
51
+ export function toApiError(error: unknown): ApiError {
52
+ if (error instanceof ApiError) return error;
53
+
54
+ if (axios.isAxiosError(error)) {
55
+ const status = error.response?.status ?? 0;
56
+ const envelope = (error.response?.data as ErrorEnvelope | undefined)?.error;
57
+ return new ApiError(
58
+ // status 0 means the request never got an answer — a dead API, DNS, or a
59
+ // CORS rejection all land here, and they read the same to the user.
60
+ envelope?.code ?? (status === 0 ? "NETWORK" : "UNKNOWN"),
61
+ envelope?.message ?? error.message,
62
+ status,
63
+ envelope?.details,
64
+ envelope?.request_id,
65
+ );
66
+ }
67
+
68
+ return new ApiError("UNKNOWN", error instanceof Error ? error.message : String(error), 0);
69
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AxiosAdapter } from "axios";
3
+
4
+ import { createApiClient } from "./client";
5
+ import { ApiError } from "./api-error";
6
+ import { setAccessToken } from "{{alias}}/shared/auth/access-token";
7
+
8
+ /**
9
+ * The two rules in the response interceptor that fail silently in a browser:
10
+ * a refresh storm logs the user out mid-session, and refreshing a login 401
11
+ * spends the cookie of whoever is already signed in. Both are testable only
12
+ * because createApiClient takes config — the adapter below stands in for the
13
+ * network.
14
+ */
15
+ function stubAdapter(handle: (url: string) => { status: number; data?: unknown }): {
16
+ adapter: AxiosAdapter;
17
+ calls: string[];
18
+ } {
19
+ const calls: string[] = [];
20
+ const adapter: AxiosAdapter = async (config) => {
21
+ const url = config.url ?? "";
22
+ calls.push(url);
23
+ const { status, data } = handle(url);
24
+ const response = { data, status, statusText: "", headers: {}, config } as never;
25
+ if (status >= 400) throw Object.assign(new Error(`HTTP ${status}`), { isAxiosError: true, config, response });
26
+ return response;
27
+ };
28
+ return { adapter, calls };
29
+ }
30
+
31
+ describe("api client", () => {
32
+ test("refreshes once for concurrent 401s, then replays both", async () => {
33
+ setAccessToken("stale");
34
+ let refreshed = false;
35
+ const { adapter, calls } = stubAdapter((url) => {
36
+ if (url === "/auth/refresh") {
37
+ refreshed = true;
38
+ return { status: 200, data: { access_token: "fresh" } };
39
+ }
40
+ return refreshed ? { status: 200, data: { ok: true } } : { status: 401 };
41
+ });
42
+
43
+ const { api } = createApiClient({ adapter });
44
+ const results = await Promise.all([api.get("/a"), api.get("/b")]);
45
+
46
+ expect(results.every((response) => response.status === 200)).toBe(true);
47
+ expect(calls.filter((url) => url === "/auth/refresh")).toHaveLength(1);
48
+ });
49
+
50
+ test("does not refresh a 401 from /auth/*", async () => {
51
+ setAccessToken(null);
52
+ const { adapter, calls } = stubAdapter(() => ({ status: 401, data: { error: { code: "AUTH_INVALID_CREDENTIALS" } } }));
53
+
54
+ const { api } = createApiClient({ adapter });
55
+ const error = await api.post("/auth/login", {}).catch((thrown) => thrown);
56
+
57
+ expect(error).toBeInstanceOf(ApiError);
58
+ expect((error as ApiError).code).toBe("AUTH_INVALID_CREDENTIALS");
59
+ expect(calls).toEqual(["/auth/login"]);
60
+ });
61
+ });
@@ -0,0 +1,91 @@
1
+ import axios, { type CreateAxiosDefaults, type InternalAxiosRequestConfig } from "axios";
2
+
3
+ import { getAccessToken, setAccessToken } from "{{alias}}/shared/auth/access-token";
4
+ import { API_URL } from "{{alias}}/shared/config";
5
+
6
+ import { toApiError } from "./api-error";
7
+
8
+ const REFRESH_PATH = "/auth/refresh";
9
+
10
+ type RetriableConfig = InternalAxiosRequestConfig & { _retried?: boolean };
11
+
12
+ type AccessTokenResponse = { access_token: string };
13
+
14
+ /**
15
+ * Builds the API client: bearer token in, normalised {@link ApiError} out, and
16
+ * one silent refresh attempt per failed request.
17
+ *
18
+ * A factory rather than a bare module instance so a test can pass its own
19
+ * adapter — the refresh handling below is the part worth pinning down.
20
+ */
21
+ export function createApiClient(defaults: CreateAxiosDefaults = {}) {
22
+ // withCredentials so the browser attaches an httpOnly refresh cookie on the
23
+ // /auth/refresh call. Cross-origin by default (:3000 -> the API), which the
24
+ // API has to allow explicitly in its CORS config.
25
+ const config: CreateAxiosDefaults = { withCredentials: true, ...defaults };
26
+ const api = axios.create(config);
27
+ // Separate instance for the refresh call itself: on `api` it would hit the
28
+ // interceptor below and try to refresh its own 401, recursively.
29
+ const bare = axios.create(config);
30
+
31
+ let inFlight: Promise<string | null> | null = null;
32
+
33
+ /**
34
+ * Trades the refresh cookie for a new access token. Single-flight: every
35
+ * request that 401s while one refresh is running awaits that same promise.
36
+ * A backend that rotates the refresh token on use would otherwise see two
37
+ * concurrent calls race, and one would invalidate the other's cookie —
38
+ * logging the user out mid-session.
39
+ */
40
+ function refreshAccessToken(): Promise<string | null> {
41
+ inFlight ??= bare
42
+ .post<AccessTokenResponse>(REFRESH_PATH)
43
+ .then(
44
+ (response) => {
45
+ const token = response.data.access_token;
46
+ setAccessToken(token);
47
+ return token;
48
+ },
49
+ () => {
50
+ // No usable cookie: drop the stale token so the next request goes
51
+ // out unauthenticated instead of retrying a dead one forever.
52
+ setAccessToken(null);
53
+ return null;
54
+ },
55
+ )
56
+ .finally(() => {
57
+ inFlight = null;
58
+ });
59
+ return inFlight;
60
+ }
61
+
62
+ api.interceptors.request.use((request) => {
63
+ const token = getAccessToken();
64
+ if (token) request.headers.Authorization = `Bearer ${token}`;
65
+ return request;
66
+ });
67
+
68
+ api.interceptors.response.use(undefined, async (error: unknown) => {
69
+ const failed = axios.isAxiosError(error) ? (error.config as RetriableConfig | undefined) : undefined;
70
+ // A 401 from /auth/* is an answer, not an expired token: wrong password, a
71
+ // spent challenge, a dead reset link. Refreshing on those would spend the
72
+ // cookie of whoever is already signed in on this browser and still
73
+ // surface the same error.
74
+ const fromAuthEndpoint = failed?.url?.startsWith("/auth/") ?? false;
75
+
76
+ if (axios.isAxiosError(error) && error.response?.status === 401 && failed && !failed._retried && !fromAuthEndpoint) {
77
+ failed._retried = true;
78
+ const token = await refreshAccessToken();
79
+ if (token) {
80
+ failed.headers.Authorization = `Bearer ${token}`;
81
+ return api.request(failed);
82
+ }
83
+ }
84
+
85
+ throw toApiError(error);
86
+ });
87
+
88
+ return { api, refreshAccessToken };
89
+ }
90
+
91
+ export const { api, refreshAccessToken } = createApiClient({ baseURL: API_URL });
@@ -0,0 +1 @@
1
+ export { API_URL } from "./env";
@@ -0,0 +1,3 @@
1
+ // Base URL of the API, including whatever prefix it mounts its routes under —
2
+ // the prefix belongs here rather than in every request path.
3
+ export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A catalog maps the API's stable machine codes onto UI copy. One catalog per
3
+ * domain that owns those codes, never one global map: the domain that raises a
4
+ * code is the only place that knows what it means to a user, and a single map
5
+ * turns into a merge conflict magnet the moment two features grow at once.
6
+ */
7
+ export type ErrorCatalog = Readonly<Record<string, string>>;
8
+
9
+ /**
10
+ * Codes any endpoint can answer with, plus the two `toApiError` mints
11
+ * client-side when the request never reached an answer at all.
12
+ *
13
+ * Every other catalog composes on top of this one, so a domain catalog only
14
+ * has to carry what is genuinely its own — generate one with
15
+ * `nextjs-fsd generate page <name> --errors`.
16
+ */
17
+ export const commonErrorCatalog: ErrorCatalog = {
18
+ {{commonCatalogEntries}}
19
+ };
@@ -0,0 +1,30 @@
1
+ import { ApiError } from "./api-error";
2
+ import type { ErrorCatalog } from "./error-catalog";
3
+
4
+ export type ResolveApiErrorOptions = {
5
+ /** Searched in order — first hit wins, so put the most specific first. */
6
+ catalogs?: readonly ErrorCatalog[];
7
+ fallback: string;
8
+ };
9
+
10
+ /**
11
+ * Turns anything a failed call can throw into one sentence, resolved from the
12
+ * catalogs the calling domain owns.
13
+ *
14
+ * `error.message` is deliberately never used as UI copy: the wording is a
15
+ * backend implementation detail that changes without anyone here noticing, and
16
+ * it is written for a developer reading a log. An unmapped code is a gap in a
17
+ * catalog, and a fallback that names the action that failed
18
+ * ("{{copy.saveFailed}}") is more useful to the user than a literal backend
19
+ * message would have been anyway.
20
+ */
21
+ export function resolveApiError(error: unknown, { catalogs = [], fallback }: ResolveApiErrorOptions): string {
22
+ if (!(error instanceof ApiError)) return fallback;
23
+
24
+ for (const catalog of catalogs) {
25
+ const message = catalog[error.code];
26
+ if (message) return message;
27
+ }
28
+
29
+ return fallback;
30
+ }
@@ -0,0 +1,50 @@
1
+ import { ApiError, commonErrorCatalog, resolveApiError, type ErrorCatalog } from "{{alias}}/shared/api";
2
+
3
+ /**
4
+ * Renders a failed API call: one sentence resolved from the caller's catalogs,
5
+ * plus per-field details when the API sent them (usually dev only, which is
6
+ * why those stay untranslated).
7
+ *
8
+ * This component owns no copy of its own. The domain that raises a code owns
9
+ * the sentence for it — `authErrorCatalogs` from `{{alias}}/shared/auth`, or a
10
+ * `_pages/<page>/model/<page>-errors.ts` — so adding a feature never means
11
+ * editing a component in shared/ui.
12
+ */
13
+ export function FormError({
14
+ error,
15
+ // Searched in order, so a screen that reads one code differently from the
16
+ // rest of its domain puts its own map first.
17
+ catalogs = [commonErrorCatalog],
18
+ fallback = "{{copy.genericError}}",
19
+ className,
20
+ }: {
21
+ error: unknown;
22
+ catalogs?: readonly ErrorCatalog[];
23
+ fallback?: string;
24
+ className?: string;
25
+ }) {
26
+ if (!error) return null;
27
+
28
+ const fields = error instanceof ApiError ? error.fieldErrors : undefined;
29
+ const message = resolveApiError(error, { catalogs, fallback });
30
+
31
+ return (
32
+ <div
33
+ role="alert"
34
+ className={["rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-400", className]
35
+ .filter(Boolean)
36
+ .join(" ")}
37
+ >
38
+ <p>{message}</p>
39
+ {fields && (
40
+ <ul className="mt-1 list-inside list-disc">
41
+ {Object.entries(fields).map(([field, rule]) => (
42
+ <li key={field}>
43
+ {field}: {rule}
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ )}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,5 @@
1
+ export { ApiError, toApiError } from "./api-error";
2
+ export { commonErrorCatalog, type ErrorCatalog } from "./error-catalog";
3
+ export { resolveApiError, type ResolveApiErrorOptions } from "./error-resolver";
4
+ export { api, createApiClient, refreshAccessToken } from "./client";
5
+ export { makeQueryClient } from "./query-client";
@@ -0,0 +1,14 @@
1
+ "use client";
2
+
3
+ import { QueryClientProvider } from "@tanstack/react-query";
4
+ import { useState, type ReactNode } from "react";
5
+
6
+ import { makeQueryClient } from "{{alias}}/shared/api";
7
+
8
+ export function Providers({ children }: { children: ReactNode }) {
9
+ // useState, not a module constant: one client per mount keeps a server
10
+ // render from sharing cache across requests.
11
+ const [queryClient] = useState(makeQueryClient);
12
+
13
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
14
+ }
@@ -0,0 +1,25 @@
1
+ import { QueryClient } from "@tanstack/react-query";
2
+
3
+ import { ApiError } from "./api-error";
4
+
5
+ /**
6
+ * One QueryClient per browser session (see _app/providers) — created through a
7
+ * function, not as a module constant, so a server render never shares cache
8
+ * between two users' requests.
9
+ */
10
+ export function makeQueryClient(): QueryClient {
11
+ return new QueryClient({
12
+ defaultOptions: {
13
+ queries: {
14
+ staleTime: 30_000,
15
+ // A 4xx is an answer, not a hiccup: retrying a 401/403/404 just delays
16
+ // the error the UI already has to handle. 5xx and network failures
17
+ // (status 0) are worth one more shot.
18
+ retry: (failureCount, error) => {
19
+ const retriable = !(error instanceof ApiError) || error.status === 0 || error.status >= 500;
20
+ return retriable && failureCount < 2;
21
+ },
22
+ },
23
+ },
24
+ });
25
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * Shell for the {{name}} routes.
5
+ *
6
+ * Takes a plain `{ children }` rather than `LayoutProps<...>`: Next emits no
7
+ * route-props type for a route group, since a group contributes nothing to the
8
+ * URL. Change it to `LayoutProps<"/{{name}}">` if you point this at a real path
9
+ * segment instead.
10
+ *
11
+ * A layout is composition, not a page — put anything with its own state or
12
+ * data in a slice and render that here.
13
+ */
14
+ export function {{pascal}}Layout({ children }: { children: ReactNode }) {
15
+ return (
16
+ <div className="flex min-h-svh flex-col">
17
+ <main className="flex-1 p-6">{children}</main>
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1 @@
1
+ export { {{pascal}}Layout as default } from "{{alias}}/_app/layouts";
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ {{#if auth}}
3
+
4
+ import { useRequireSession } from "{{alias}}/shared/auth";
5
+ {{/if}}
6
+
7
+ export function {{pascal}}Content() {
8
+ {{#if auth}}
9
+ // UX only. The API's own auth middleware is the real gate and it runs on
10
+ // every request no matter what the browser rendered, so never treat a
11
+ // rendered page as proof of a session.
12
+ const session = useRequireSession();
13
+ if (session.isPending || session.isError) return <p className="text-sm">{{copy.loading}}</p>;
14
+
15
+ {{/if}}
16
+ return <p className="text-sm">{{copy.todo}}</p>;
17
+ }
@@ -0,0 +1,22 @@
1
+ import { commonErrorCatalog, resolveApiError, type ErrorCatalog } from "{{alias}}/shared/api";
2
+
3
+ /**
4
+ * Machine codes this page's own endpoints answer with, mapped to the sentence
5
+ * a user should read.
6
+ *
7
+ * One catalog per domain that owns the codes, never one global map: the domain
8
+ * that raises a code is the only place that knows what it means to a user, and
9
+ * a single map turns into a merge conflict magnet the moment two features grow
10
+ * at once.
11
+ */
12
+ export const {{camel}}ErrorCatalog: ErrorCatalog = {
13
+ // "{{screaming}}_NOT_FOUND": "…",
14
+ };
15
+
16
+ /** Pass to `<FormError catalogs={...}>`. Most specific first. */
17
+ export const {{camel}}ErrorCatalogs: readonly ErrorCatalog[] = [{{camel}}ErrorCatalog, commonErrorCatalog];
18
+
19
+ /** For copy outside a `<FormError>` — a toast, a heading, a redirect reason. */
20
+ export function resolve{{pascal}}Error(error: unknown, fallback = "{{copy.genericError}}"): string {
21
+ return resolveApiError(error, { catalogs: {{camel}}ErrorCatalogs, fallback });
22
+ }
@@ -0,0 +1 @@
1
+ export { {{pascal}}Page, metadata } from "./ui/{{name}}-page";
@@ -0,0 +1,22 @@
1
+ import type { Metadata } from "next";
2
+ {{#if hasContent}}
3
+ import { {{pascal}}Content } from "./{{name}}-content";
4
+ {{/if}}
5
+
6
+ export const metadata: Metadata = { title: "{{title}}" };
7
+
8
+ // Server component: no state, no effects, no browser API. Anything that needs
9
+ // those goes in a "use client" leaf under ui/ so this page's whole tree does
10
+ // not ship to the browser.
11
+ export function {{pascal}}Page() {
12
+ return (
13
+ <div className="flex flex-col gap-6">
14
+ <h1 className="text-2xl font-semibold">{{title}}</h1>
15
+ {{#if hasContent}}
16
+ <{{pascal}}Content />
17
+ {{else}}
18
+ <p className="text-sm">{{copy.todo}}</p>
19
+ {{/if}}
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,3 @@
1
+ // Routing only — `metadata` travels with the component, because a route file
2
+ // that re-exports `default` alone silently drops the page's title.
3
+ export { {{pascal}}Page as default, metadata } from "{{alias}}/_pages/{{name}}";
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
4
+
5
+ import { ApiError, api } from "{{alias}}/shared/api";
6
+
7
+ /**
8
+ * The record this slice is about. TODO: replace with the shape the API
9
+ * actually answers.
10
+ *
11
+ * Named `{{pascal}}Record` rather than `{{pascal}}` so it can share a public API
12
+ * with the `{{pascal}}` component in ui/ — one `index.ts` cannot re-export two
13
+ * different things under one name, and a type nobody can import through the
14
+ * slice's public API is a type nobody can annotate against without breaking
15
+ * the import boundary.
16
+ */
17
+ export type {{pascal}}Record = { id: string };
18
+
19
+ export const {{camel}}Key = ["{{name}}"] as const;
20
+
21
+ // Always through `api` — a bare fetch skips the bearer token, the
22
+ // single-flight 401 refresh, and the conversion into ApiError.
23
+ export function use{{pascal}}Query(): UseQueryResult<{{pascal}}Record[], ApiError> {
24
+ return useQuery<{{pascal}}Record[], ApiError>({
25
+ queryKey: {{camel}}Key,
26
+ queryFn: () => api.get<{{pascal}}Record[]>("/{{name}}").then((response) => response.data),
27
+ });
28
+ }
29
+
30
+ /**
31
+ * A write invalidates the keys it affected rather than writing the new value
32
+ * into the cache by hand — unless the response actually carries the whole
33
+ * object, the server is still the only thing that knows what got stored
34
+ * (generated ids, defaults, computed fields).
35
+ */
36
+ export function useCreate{{pascal}}() {
37
+ const queryClient = useQueryClient();
38
+ return useMutation<{{pascal}}Record, ApiError, Omit<{{pascal}}Record, "id">>({
39
+ mutationFn: (input) => api.post<{{pascal}}Record>("/{{name}}", input).then((response) => response.data),
40
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: {{camel}}Key }),
41
+ });
42
+ }
@@ -0,0 +1,22 @@
1
+ import { commonErrorCatalog, resolveApiError, type ErrorCatalog } from "{{alias}}/shared/api";
2
+
3
+ /**
4
+ * Machine codes this page's own endpoints answer with, mapped to the sentence
5
+ * a user should read.
6
+ *
7
+ * One catalog per domain that owns the codes, never one global map: the domain
8
+ * that raises a code is the only place that knows what it means to a user, and
9
+ * a single map turns into a merge conflict magnet the moment two features grow
10
+ * at once.
11
+ */
12
+ export const {{camel}}ErrorCatalog: ErrorCatalog = {
13
+ // "{{screaming}}_NOT_FOUND": "…",
14
+ };
15
+
16
+ /** Pass to `<FormError catalogs={...}>`. Most specific first. */
17
+ export const {{camel}}ErrorCatalogs: readonly ErrorCatalog[] = [{{camel}}ErrorCatalog, commonErrorCatalog];
18
+
19
+ /** For copy outside a `<FormError>` — a toast, a heading, a redirect reason. */
20
+ export function resolve{{pascal}}Error(error: unknown, fallback = "{{copy.genericError}}"): string {
21
+ return resolveApiError(error, { catalogs: {{camel}}ErrorCatalogs, fallback });
22
+ }
@@ -0,0 +1,15 @@
1
+ {{#if segments.ui}}
2
+ export { {{pascal}} } from "./ui/{{name}}";
3
+ {{/if}}
4
+ {{#if segments.model}}
5
+ export { use{{pascal}} } from "./model/{{name}}";
6
+ {{/if}}
7
+ {{#if segments.api}}
8
+ export { {{camel}}Key, useCreate{{pascal}}, use{{pascal}}Query, type {{pascal}}Record } from "./api/{{name}}";
9
+ {{/if}}
10
+ {{#if segments.lib}}
11
+ export { format{{pascal}} } from "./lib/{{name}}";
12
+ {{/if}}
13
+ {{#if segments.errors}}
14
+ export { {{camel}}ErrorCatalog, {{camel}}ErrorCatalogs, resolve{{pascal}}Error } from "./model/{{name}}-errors";
15
+ {{/if}}
@@ -0,0 +1,4 @@
1
+ /** Pure helpers for this slice. No React, no requests — those are ui/ and api/. */
2
+ export function format{{pascal}}(value: string): string {
3
+ return value;
4
+ }
@@ -0,0 +1,10 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ /** State this slice owns and that never leaves the browser. Anything that
6
+ * comes from the API belongs in api/ as a TanStack Query hook instead. */
7
+ export function use{{pascal}}() {
8
+ const [state, setState] = useState<string | null>(null);
9
+ return { state, setState };
10
+ }
@@ -0,0 +1,17 @@
1
+ {{#if needsClient}}
2
+ "use client";
3
+
4
+ {{/if}}
5
+ {{#if segments.model}}
6
+ import { use{{pascal}} } from "../model/{{name}}";
7
+
8
+ {{/if}}
9
+ export function {{pascal}}() {
10
+ {{#if segments.model}}
11
+ const {{camel}} = use{{pascal}}();
12
+
13
+ return <p className="text-sm">{JSON.stringify({{camel}})}</p>;
14
+ {{else}}
15
+ return <p className="text-sm">{{copy.todo}}</p>;
16
+ {{/if}}
17
+ }
@@ -0,0 +1,25 @@
1
+
2
+ ## Frontend architecture — Feature-Sliced Design
3
+
4
+ `{{srcDir}}/` uses FSD v2.1 with the layers `_app`, `_pages`, `shared`
5
+ (`features`/`entities` are added when a second consumer appears). Read
6
+ [docs/fsd.md](docs/fsd.md) before adding a file under `{{srcDir}}/` — it has the
7
+ import boundary, the segment rules, and why `{{appDir}}/` only ever re-exports.
8
+
9
+ Rules that are easy to break without noticing:
10
+
11
+ - imports point downwards only, and a slice is imported through its `index.ts`
12
+ - `{{appDir}}/<route>/page.tsx` re-exports the FSD page **and its `metadata`** — nothing else
13
+ - `"use client"` goes on the leaf that needs it, not on the page
14
+ - `{{lintCommand}}` runs both linters: ESLint (`eslint.fsd.mjs`) flags a wrong-way or slice-internal import per file, steiger (`steiger.config.ts`) flags whole-tree problems like a slice with no consumers
15
+ - editing `eslint.fsd.mjs`: flat config replaces a rule's options when a later block matches the same file, so all of a layer's `no-restricted-imports` patterns must stay in that layer's one block
16
+
17
+ Claude Code also loads this as a skill at
18
+ `.claude/skills/nextjs-fsd/SKILL.md`, which carries the same contract in the
19
+ form it reads first. `docs/fsd.md` is the source of truth if the two disagree.
20
+
21
+ Scaffold with `nextjs-fsd generate page|slice|layout` rather than by hand, so
22
+ slices keep the same shape. Re-running one extends what exists (adds a
23
+ segment, a leaf, an error catalog) and never rewrites a file you already have.
24
+ `shadcn add <name>` is already aimed at `{{srcDir}}/shared/ui` by
25
+ `components.json`.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "{{srcDir}}/_app/styles/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "aliases": {
15
+ "components": "{{alias}}/shared",
16
+ "ui": "{{alias}}/shared/ui",
17
+ "lib": "{{alias}}/shared/lib",
18
+ "utils": "{{alias}}/shared/lib/utils",
19
+ "hooks": "{{alias}}/shared/lib"
20
+ }
21
+ }