@dennisrongo/skills 0.1.3 → 0.2.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.
- package/README.md +23 -13
- package/package.json +1 -1
- package/skills/nextjs-app-router/SKILL.md +228 -175
- package/skills/nextjs-app-router/references/anti-patterns.md +163 -99
- package/skills/nextjs-app-router/references/folder-layout.md +79 -46
- package/skills/nextjs-app-router/references/good-patterns.md +233 -99
- package/skills/nextjs-app-router/references/templates/api-base.md +24 -21
- package/skills/nextjs-app-router/references/templates/auth-slice.md +82 -78
- package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +58 -6
- package/skills/nextjs-app-router/references/templates/db-client.md +48 -0
- package/skills/nextjs-app-router/references/templates/env-and-utils.md +90 -23
- package/skills/nextjs-app-router/references/templates/feature-slice.md +110 -47
- package/skills/nextjs-app-router/references/templates/form-with-zod.md +23 -15
- package/skills/nextjs-app-router/references/templates/middleware.md +69 -59
- package/skills/nextjs-app-router/references/templates/next-config.md +4 -14
- package/skills/nextjs-app-router/references/templates/nextauth-config.md +178 -0
- package/skills/nextjs-app-router/references/templates/package.md +35 -5
- package/skills/nextjs-app-router/references/templates/prisma-schema.md +162 -0
- package/skills/nextjs-app-router/references/templates/providers-and-store.md +35 -21
- package/skills/nextjs-app-router/references/templates/root-layout.md +99 -20
- package/skills/nextjs-app-router/references/templates/route-group-layouts.md +46 -6
- package/skills/nextjs-app-router/references/templates/route-handler.md +168 -0
- package/skills/nextjs-app-router/references/templates/testing.md +105 -31
|
@@ -1,34 +1,151 @@
|
|
|
1
1
|
# Good Patterns to Keep
|
|
2
2
|
|
|
3
|
-
These are the patterns the scaffolder must emit by default. Each one is here because it solves a recurring problem in real-world Next.js +
|
|
3
|
+
These are the patterns the scaffolder must emit by default. Each one is here because it solves a recurring problem in real-world Next.js + NextAuth + Prisma + Redux Toolkit projects.
|
|
4
4
|
|
|
5
5
|
## 1. Route groups for auth and role boundaries
|
|
6
6
|
|
|
7
|
-
`app/(public)/`, `app/(app)/`, `app/(admin)/` carve the route tree by who is allowed in. Each group has its own `layout.tsx
|
|
7
|
+
`app/(public)/`, `app/(app)/`, `app/(admin)/` carve the route tree by who is allowed in. Each group has its own `layout.tsx`. The `middleware.ts` `config.matcher` mirrors the group structure and gates on the server.
|
|
8
8
|
|
|
9
|
-
**Why it works:** auth boundaries become a property of the file system, not a runtime check sprinkled into every page.
|
|
9
|
+
**Why it works:** auth boundaries become a property of the file system, not a runtime check sprinkled into every page.
|
|
10
10
|
|
|
11
|
-
## 2.
|
|
11
|
+
## 2. SPA-style pages, server root layout only
|
|
12
12
|
|
|
13
|
-
`app/layout.tsx
|
|
13
|
+
`src/app/layout.tsx` is a server component that renders `<html><body><Providers>{children}</Providers></body></html>`. **Every other `page.tsx` is `'use client'`** and uses RTK Query hooks for data. Per-route metadata lives at the layout level (server) — pages can't export `metadata` because they're client. This is the deliberate "API-driven, not SSR" choice.
|
|
14
14
|
|
|
15
|
-
**Why it works:**
|
|
15
|
+
**Why it works:** one mental model for data flow (UI → RTK Query → /api → DB), trivial mocking in tests, no "server or client?" cargo culting, no second mutation path via Server Actions.
|
|
16
16
|
|
|
17
|
-
## 3.
|
|
17
|
+
## 3. NextAuth (Auth.js v5) as the single auth source
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
```ts
|
|
20
|
+
// src/auth.ts
|
|
21
|
+
import NextAuth from 'next-auth';
|
|
22
|
+
import { PrismaAdapter } from '@auth/prisma-adapter';
|
|
23
|
+
import { db } from '@/lib/db';
|
|
24
|
+
import authConfig from './auth.config';
|
|
25
|
+
|
|
26
|
+
export const { handlers, auth, signIn, signOut } = NextAuth({
|
|
27
|
+
...authConfig,
|
|
28
|
+
adapter: PrismaAdapter(db),
|
|
29
|
+
session: { strategy: 'jwt' }, // required when Credentials provider is present
|
|
30
|
+
});
|
|
31
|
+
```
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
```ts
|
|
34
|
+
// src/auth.config.ts — EDGE-SAFE (no DB imports)
|
|
35
|
+
import type { NextAuthConfig } from 'next-auth';
|
|
36
|
+
import Credentials from 'next-auth/providers/credentials';
|
|
37
|
+
|
|
38
|
+
export default {
|
|
39
|
+
providers: [Credentials({ /* authorize: dynamically imported in auth.ts */ })],
|
|
40
|
+
pages: { signIn: '/auth/login' },
|
|
41
|
+
callbacks: {
|
|
42
|
+
async jwt({ token, user }) { if (user) token.id = user.id; return token; },
|
|
43
|
+
async session({ session, token }) { if (token?.id) session.user.id = token.id as string; return session; },
|
|
44
|
+
},
|
|
45
|
+
} satisfies NextAuthConfig;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// middleware.ts
|
|
50
|
+
import NextAuth from 'next-auth';
|
|
51
|
+
import authConfig from '@/auth.config';
|
|
52
|
+
export const { auth: middleware } = NextAuth(authConfig);
|
|
53
|
+
export const config = { matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'] };
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Why split into two files:** middleware runs on the Edge runtime, which can't import the Prisma adapter. `auth.config.ts` is Edge-safe; `auth.ts` extends it with the adapter and is only imported from Node code (Route Handlers, server components).
|
|
57
|
+
|
|
58
|
+
## 4. Route Handlers as the backend
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// src/app/api/customers/route.ts
|
|
62
|
+
import { NextResponse } from 'next/server';
|
|
63
|
+
import { db } from '@/lib/db';
|
|
64
|
+
import { requireSession } from '@/lib/api-auth';
|
|
65
|
+
import { customerSchema } from '@/app/(app)/customers/schema';
|
|
66
|
+
|
|
67
|
+
export async function GET() {
|
|
68
|
+
const session = await requireSession();
|
|
69
|
+
const customers = await db.customer.findMany({ where: { userId: session.user.id } });
|
|
70
|
+
return NextResponse.json(customers);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function POST(req: Request) {
|
|
74
|
+
const session = await requireSession();
|
|
75
|
+
const parsed = customerSchema.safeParse(await req.json());
|
|
76
|
+
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
77
|
+
const created = await db.customer.create({ data: { ...parsed.data, userId: session.user.id } });
|
|
78
|
+
return NextResponse.json(created, { status: 201 });
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Why it works:** every backend operation has one home, one auth check, one Zod validation pass against the same schema the form uses, one Prisma call. Ownership is enforced via `userId: session.user.id`.
|
|
83
|
+
|
|
84
|
+
## 5. `requireSession()` helper for handlers
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// src/lib/api-auth.ts
|
|
88
|
+
import { auth } from '@/auth';
|
|
89
|
+
|
|
90
|
+
export async function requireSession() {
|
|
91
|
+
const session = await auth();
|
|
92
|
+
if (!session?.user?.id) {
|
|
93
|
+
throw new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
94
|
+
status: 401,
|
|
95
|
+
headers: { 'content-type': 'application/json' },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return session as typeof session & { user: { id: string } };
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Handlers `await requireSession()`; on failure, the thrown `Response` bubbles up as a 401 (Next handles thrown Responses in handlers cleanly in v14+; if not, wrap in try/catch and return). Saves five lines in every handler and guarantees the check exists.
|
|
103
|
+
|
|
104
|
+
## 6. Prisma singleton
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// src/lib/db.ts
|
|
108
|
+
import { PrismaClient } from '@prisma/client';
|
|
109
|
+
|
|
110
|
+
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
|
111
|
+
|
|
112
|
+
export const db = globalForPrisma.prisma ?? new PrismaClient();
|
|
113
|
+
|
|
114
|
+
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Why it works:** dev hot-reload creates new modules but reuses the global. Without this, you exhaust your Postgres connection pool in five minutes of editing.
|
|
118
|
+
|
|
119
|
+
## 7. One RTK Query base, many injected slices
|
|
22
120
|
|
|
23
121
|
```ts
|
|
24
122
|
// redux/api/api.ts
|
|
123
|
+
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
|
124
|
+
import { signOut } from 'next-auth/react';
|
|
125
|
+
import { TAG_TYPES } from './tags';
|
|
126
|
+
|
|
127
|
+
const rawBaseQuery = fetchBaseQuery({
|
|
128
|
+
baseUrl: '/api',
|
|
129
|
+
credentials: 'include', // ride the NextAuth session cookie (same-origin)
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export const baseQueryWithAuth: BaseQueryFn<...> = async (args, api, extra) => {
|
|
133
|
+
const result = await rawBaseQuery(args, api, extra);
|
|
134
|
+
if (result.error?.status === 401) {
|
|
135
|
+
void signOut({ callbackUrl: '/auth/login', redirect: true });
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
};
|
|
139
|
+
|
|
25
140
|
export const api = createApi({
|
|
26
141
|
reducerPath: 'api',
|
|
27
|
-
baseQuery:
|
|
142
|
+
baseQuery: baseQueryWithAuth,
|
|
28
143
|
tagTypes: TAG_TYPES,
|
|
29
144
|
endpoints: () => ({}),
|
|
30
145
|
});
|
|
146
|
+
```
|
|
31
147
|
|
|
148
|
+
```ts
|
|
32
149
|
// redux/api/customersApi.ts
|
|
33
150
|
export const customersApi = api.injectEndpoints({
|
|
34
151
|
endpoints: (build) => ({
|
|
@@ -44,37 +161,39 @@ export const customersApi = api.injectEndpoints({
|
|
|
44
161
|
});
|
|
45
162
|
```
|
|
46
163
|
|
|
47
|
-
**Why it works:** one cache, one middleware, one set of tag types. Cross-domain invalidation just works
|
|
48
|
-
|
|
49
|
-
##
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
export
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
164
|
+
**Why it works:** one cache, one middleware, one set of tag types. Cross-domain invalidation just works. `baseUrl: '/api'` keeps requests same-origin so the NextAuth session cookie rides along.
|
|
165
|
+
|
|
166
|
+
## 8. SessionProvider + Redux Provider co-located
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
// src/redux/providers.tsx
|
|
170
|
+
'use client';
|
|
171
|
+
import { SessionProvider } from 'next-auth/react';
|
|
172
|
+
import { Provider } from 'react-redux';
|
|
173
|
+
import { AppProgressBar as ProgressBar } from 'next-nprogress-bar';
|
|
174
|
+
import { Toaster } from '@/components/ui/toaster';
|
|
175
|
+
import { store } from './store';
|
|
176
|
+
|
|
177
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
178
|
+
return (
|
|
179
|
+
<SessionProvider>
|
|
180
|
+
<Provider store={store}>
|
|
181
|
+
{children}
|
|
182
|
+
<Toaster />
|
|
183
|
+
<ProgressBar height="3px" color="hsl(var(--primary))" shallowRouting options={{ showSpinner: false }} />
|
|
184
|
+
</Provider>
|
|
185
|
+
</SessionProvider>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
69
188
|
```
|
|
70
189
|
|
|
71
|
-
**Why
|
|
190
|
+
**Why this order:** `SessionProvider` outside so any component (including Redux-connected ones) can `useSession()`. Redux inside so RTK Query mutations can dispatch.
|
|
72
191
|
|
|
73
|
-
##
|
|
192
|
+
## 9. Typed Redux hooks
|
|
74
193
|
|
|
75
|
-
`redux/hooks.ts` exports `useAppDispatch` and `useAppSelector` typed against `RootState` and `AppDispatch`. Components never import the raw `useDispatch` / `useSelector
|
|
194
|
+
`redux/hooks.ts` exports `useAppDispatch` and `useAppSelector` typed against `RootState` and `AppDispatch`. Components never import the raw `useDispatch` / `useSelector`.
|
|
76
195
|
|
|
77
|
-
##
|
|
196
|
+
## 10. React Hook Form + Zod with schema-introspected defaults
|
|
78
197
|
|
|
79
198
|
```ts
|
|
80
199
|
const schema = z.object({ name: z.string().min(1), age: z.number().min(0) });
|
|
@@ -85,50 +204,44 @@ const form = useForm<z.infer<typeof schema>>({
|
|
|
85
204
|
});
|
|
86
205
|
```
|
|
87
206
|
|
|
88
|
-
`getDefaultValuesFromSchema` walks the Zod tree and produces a defaults object
|
|
89
|
-
|
|
90
|
-
`getMaxLengthsFromSchema` reads `z.string().max(N)` and feeds the `maxLength` attribute to the rendered `<Input>`.
|
|
91
|
-
|
|
92
|
-
**Why it works:** the schema is the single source of truth for both validation and field configuration. No drift between "what the form accepts" and "what the API will accept."
|
|
93
|
-
|
|
94
|
-
## 9. Composed form fields built on shadcn `<Form>`
|
|
207
|
+
`getDefaultValuesFromSchema` walks the Zod tree and produces a defaults object matching the schema shape. **The same `schema` is imported in `app/api/<feature>/route.ts` and used to validate request bodies** — one source of truth for form + handler validation.
|
|
95
208
|
|
|
96
|
-
|
|
209
|
+
## 11. Composed form fields built on shadcn `<Form>`
|
|
97
210
|
|
|
98
|
-
|
|
211
|
+
`components/forms/FormInputField.tsx` and siblings take `form`, `schema`, `fieldName`, `formLabel` props and render a fully wired `<FormField>`/`<FormItem>`/`<FormLabel>`/`<FormControl>`/`<FormMessage>` block. Every form reuses these.
|
|
99
212
|
|
|
100
|
-
##
|
|
213
|
+
## 12. `UnsavedChangesWarning` tied to RHF `formState.isDirty`
|
|
101
214
|
|
|
102
|
-
A small component that hooks into
|
|
215
|
+
A small component that hooks into `window.beforeunload` (and Next router events when the API stabilizes) to warn when a form has unsaved changes.
|
|
103
216
|
|
|
104
|
-
##
|
|
217
|
+
## 13. `error.tsx`, `loading.tsx`, `not-found.tsx` at meaningful segments
|
|
105
218
|
|
|
106
|
-
At minimum: global ones at `app/`, group-level ones at `(app)/`, and feature-level `loading.tsx` for any feature that fetches data
|
|
219
|
+
At minimum: global ones at `app/`, group-level ones at `(app)/`, and feature-level `loading.tsx` for any feature that fetches data.
|
|
107
220
|
|
|
108
|
-
##
|
|
221
|
+
## 14. Runtime env validation
|
|
109
222
|
|
|
110
223
|
```ts
|
|
111
224
|
// src/config/env.ts
|
|
112
225
|
import { z } from 'zod';
|
|
113
226
|
|
|
114
227
|
const envSchema = z.object({
|
|
115
|
-
|
|
116
|
-
|
|
228
|
+
AUTH_SECRET: z.string().min(32),
|
|
229
|
+
AUTH_URL: z.string().url().optional(), // required in prod, optional locally
|
|
230
|
+
DATABASE_URL: z.string().url(),
|
|
231
|
+
AUTH_GITHUB_ID: z.string().optional(),
|
|
232
|
+
AUTH_GITHUB_SECRET: z.string().optional(),
|
|
117
233
|
});
|
|
118
234
|
|
|
119
|
-
export const env = envSchema.parse(
|
|
120
|
-
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
|
|
121
|
-
NEXT_PUBLIC_PUSHER_KEY: process.env.NEXT_PUBLIC_PUSHER_KEY,
|
|
122
|
-
});
|
|
235
|
+
export const env = envSchema.parse(process.env);
|
|
123
236
|
```
|
|
124
237
|
|
|
125
|
-
**Why
|
|
238
|
+
**Why:** misconfiguration crashes the app at startup with a clear error, not deep inside a Route Handler with a cryptic `undefined`.
|
|
126
239
|
|
|
127
|
-
##
|
|
240
|
+
## 15. shadcn primitives owned by the project
|
|
128
241
|
|
|
129
|
-
`components/ui/button.tsx`, `input.tsx`, `form.tsx`, etc. live in the repo and are editable.
|
|
242
|
+
`components/ui/button.tsx`, `input.tsx`, `form.tsx`, etc. live in the repo and are editable. Not pulled from `node_modules`.
|
|
130
243
|
|
|
131
|
-
##
|
|
244
|
+
## 16. `cn()` in `lib/utils.ts`
|
|
132
245
|
|
|
133
246
|
```ts
|
|
134
247
|
import { clsx, type ClassValue } from 'clsx';
|
|
@@ -136,77 +249,98 @@ import { twMerge } from 'tailwind-merge';
|
|
|
136
249
|
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
|
|
137
250
|
```
|
|
138
251
|
|
|
139
|
-
Single source of class merging
|
|
252
|
+
Single source of class merging.
|
|
140
253
|
|
|
141
|
-
##
|
|
254
|
+
## 17. Per-feature `_components/` and `_hooks/`
|
|
142
255
|
|
|
143
|
-
Co-locate
|
|
256
|
+
Co-locate implementation details with the route. When a feature is deleted, the entire folder goes with it. Promote to `src/components/` only when a second feature genuinely needs it.
|
|
144
257
|
|
|
145
|
-
##
|
|
258
|
+
## 18. One date library: `date-fns`
|
|
146
259
|
|
|
147
|
-
Tree-shakeable, immutable, ESM-friendly. Format helpers live in `lib/formatters/date.ts
|
|
260
|
+
Tree-shakeable, immutable, ESM-friendly. Format helpers live in `lib/formatters/date.ts`.
|
|
148
261
|
|
|
149
|
-
##
|
|
262
|
+
## 19. Strict TypeScript
|
|
150
263
|
|
|
151
|
-
`
|
|
264
|
+
`"strict": true`, `"noUncheckedIndexedAccess": true`, `"forceConsistentCasingInFileNames": true`. The last one catches the duplicate-by-case folder bug at compile time.
|
|
152
265
|
|
|
153
|
-
##
|
|
266
|
+
## 20. Strict ESLint
|
|
154
267
|
|
|
155
|
-
`@typescript-eslint/recommended`, `
|
|
268
|
+
`@typescript-eslint/recommended`, `react-hooks/recommended`, `jsx-a11y/recommended`, plus `no-console: warn`, `no-explicit-any: error`, `exhaustive-deps: error`. Pre-commit hook runs `eslint --fix`.
|
|
156
269
|
|
|
157
|
-
##
|
|
270
|
+
## 21. `middleware.ts` as the auth gate
|
|
158
271
|
|
|
159
272
|
```ts
|
|
160
273
|
// middleware.ts
|
|
161
|
-
import
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (!session && req.nextUrl.pathname.startsWith('/app')) {
|
|
166
|
-
const url = req.nextUrl.clone();
|
|
167
|
-
url.pathname = '/auth/login';
|
|
168
|
-
url.searchParams.set('returnTo', req.nextUrl.pathname);
|
|
169
|
-
return NextResponse.redirect(url);
|
|
170
|
-
}
|
|
171
|
-
return NextResponse.next();
|
|
172
|
-
}
|
|
274
|
+
import NextAuth from 'next-auth';
|
|
275
|
+
import authConfig from '@/auth.config';
|
|
276
|
+
|
|
277
|
+
export const { auth: middleware } = NextAuth(authConfig);
|
|
173
278
|
|
|
174
279
|
export const config = {
|
|
175
|
-
|
|
280
|
+
// /api/auth is NextAuth's own; static assets excluded.
|
|
281
|
+
matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
|
|
176
282
|
};
|
|
177
283
|
```
|
|
178
284
|
|
|
179
|
-
|
|
285
|
+
Path-specific redirects (e.g. `/` → `/app/dashboard` for signed-in users) live in a custom wrapper around `auth()` if needed.
|
|
180
286
|
|
|
181
|
-
##
|
|
287
|
+
## 22. Prisma migrations checked in
|
|
182
288
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
289
|
+
`prisma/migrations/**` is committed. CI runs `prisma migrate deploy`. `prisma db push` is only for throwaway prototyping.
|
|
290
|
+
|
|
291
|
+
## 23. Module augmentation for `Session.user.id` / role
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
// src/types/next-auth.d.ts
|
|
295
|
+
import 'next-auth';
|
|
296
|
+
|
|
297
|
+
declare module 'next-auth' {
|
|
298
|
+
interface Session {
|
|
299
|
+
user: { id: string; email: string; name?: string | null; image?: string | null; role?: string };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
declare module 'next-auth/jwt' {
|
|
304
|
+
interface JWT { id: string; role?: string }
|
|
305
|
+
}
|
|
306
|
+
```
|
|
187
307
|
|
|
188
|
-
|
|
308
|
+
Without this, `session.user.id` is `undefined` in TypeScript even though the runtime callback added it.
|
|
189
309
|
|
|
190
|
-
##
|
|
310
|
+
## 24. Vitest + RTL for unit, Playwright for E2E
|
|
311
|
+
|
|
312
|
+
A new project gets at least:
|
|
313
|
+
- One reducer/slice test.
|
|
314
|
+
- One Zod schema test (validates valid + invalid payloads).
|
|
315
|
+
- One form-renders test.
|
|
316
|
+
- One Playwright spec that logs in (Credentials), lands on dashboard, signs out.
|
|
317
|
+
|
|
318
|
+
## 25. Husky + lint-staged
|
|
191
319
|
|
|
192
320
|
```jsonc
|
|
193
|
-
// package.json
|
|
194
321
|
"lint-staged": {
|
|
195
322
|
"*.{ts,tsx}": ["prettier --write", "eslint --fix"],
|
|
196
323
|
"*.{json,md,css}": ["prettier --write"]
|
|
197
324
|
}
|
|
198
325
|
```
|
|
199
326
|
|
|
200
|
-
|
|
327
|
+
## 26. GitHub Actions CI with a Postgres service
|
|
201
328
|
|
|
202
|
-
|
|
329
|
+
```yaml
|
|
330
|
+
services:
|
|
331
|
+
postgres:
|
|
332
|
+
image: postgres:16
|
|
333
|
+
env: { POSTGRES_PASSWORD: postgres }
|
|
334
|
+
ports: ['5432:5432']
|
|
335
|
+
options: --health-cmd pg_isready --health-interval 10s
|
|
336
|
+
```
|
|
203
337
|
|
|
204
|
-
|
|
338
|
+
Steps: install → `prisma migrate deploy` → lint → typecheck → test → build.
|
|
205
339
|
|
|
206
|
-
##
|
|
340
|
+
## 27. `next-nprogress-bar` for top loading bar
|
|
207
341
|
|
|
208
|
-
One small, well-maintained library
|
|
342
|
+
One small, well-maintained library. Configured in `providers.tsx`.
|
|
209
343
|
|
|
210
|
-
##
|
|
344
|
+
## 28. Toaster via shadcn `use-toast`
|
|
211
345
|
|
|
212
|
-
|
|
346
|
+
One toast library. Composed `Notifications.tsx` wraps it; features call the wrapper.
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
```ts
|
|
6
6
|
// Single source of truth for cache tags. Every domain adds to this union.
|
|
7
7
|
export const TAG_TYPES = [
|
|
8
|
-
'Auth',
|
|
9
8
|
// Add domain tags here as features are added, e.g. 'Customers', 'Customer', 'Invoices', 'Invoice'.
|
|
10
9
|
] as const;
|
|
11
10
|
|
|
@@ -15,34 +14,34 @@ export type TagType = (typeof TAG_TYPES)[number];
|
|
|
15
14
|
## `src/redux/api/api.ts`
|
|
16
15
|
|
|
17
16
|
```ts
|
|
18
|
-
import {
|
|
19
|
-
|
|
17
|
+
import {
|
|
18
|
+
createApi,
|
|
19
|
+
fetchBaseQuery,
|
|
20
|
+
type BaseQueryFn,
|
|
21
|
+
type FetchArgs,
|
|
22
|
+
type FetchBaseQueryError,
|
|
23
|
+
} from '@reduxjs/toolkit/query/react';
|
|
24
|
+
import { signOut } from 'next-auth/react';
|
|
20
25
|
import { TAG_TYPES } from './tags';
|
|
21
|
-
import {
|
|
26
|
+
import { signOutEvent } from '../store';
|
|
22
27
|
|
|
23
28
|
const rawBaseQuery = fetchBaseQuery({
|
|
24
|
-
baseUrl:
|
|
25
|
-
//
|
|
29
|
+
baseUrl: '/api',
|
|
30
|
+
// Same-origin — the NextAuth session cookie rides along automatically.
|
|
26
31
|
credentials: 'include',
|
|
27
|
-
prepareHeaders: (headers, { getState }) => {
|
|
28
|
-
// If the project uses a JS-readable bearer token, attach it here.
|
|
29
|
-
// For httpOnly cookie flows, leave this empty — the browser sends the cookie.
|
|
30
|
-
const token = (getState() as { auth?: { token?: string } }).auth?.token;
|
|
31
|
-
if (token) headers.set('Authorization', `Bearer ${token}`);
|
|
32
|
-
return headers;
|
|
33
|
-
},
|
|
34
32
|
});
|
|
35
33
|
|
|
36
|
-
export const baseQueryWithAuth: BaseQueryFn<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
) => {
|
|
34
|
+
export const baseQueryWithAuth: BaseQueryFn<
|
|
35
|
+
string | FetchArgs,
|
|
36
|
+
unknown,
|
|
37
|
+
FetchBaseQueryError
|
|
38
|
+
> = async (args, api, extraOptions) => {
|
|
41
39
|
const result = await rawBaseQuery(args, api, extraOptions);
|
|
42
40
|
|
|
43
41
|
if (result.error?.status === 401) {
|
|
44
|
-
//
|
|
45
|
-
api.dispatch(
|
|
42
|
+
// Wipe Redux state first, then let NextAuth clear the cookie and redirect.
|
|
43
|
+
api.dispatch(signOutEvent);
|
|
44
|
+
void signOut({ callbackUrl: '/auth/login', redirect: true });
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
return result;
|
|
@@ -57,6 +56,10 @@ export const api = createApi({
|
|
|
57
56
|
```
|
|
58
57
|
|
|
59
58
|
**Notes:**
|
|
59
|
+
|
|
60
|
+
- `baseUrl: '/api'` — requests go to the in-app Route Handlers under `src/app/api/**`. Same-origin means the NextAuth session cookie is sent automatically.
|
|
61
|
+
- `credentials: 'include'` — required so the cookie rides on every request. Don't drop it.
|
|
60
62
|
- `createApi` is called **once** in this file. Every other `*Api.ts` file uses `api.injectEndpoints(...)`.
|
|
61
|
-
- The 401 handler dispatches
|
|
63
|
+
- The 401 handler dispatches `signOutEvent` (resets Redux state via the root reducer) then calls NextAuth's `signOut` which clears the cookie and routes to `/auth/login`. **Never** `window.location.href = '...'` here.
|
|
62
64
|
- `tagTypes` reads from the shared `TAG_TYPES` constant — domains add to that one array, not here.
|
|
65
|
+
- No `prepareHeaders` injecting an `Authorization: Bearer` token. NextAuth uses an `httpOnly` session cookie; the browser handles it.
|