@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.
Files changed (23) hide show
  1. package/README.md +23 -13
  2. package/package.json +1 -1
  3. package/skills/nextjs-app-router/SKILL.md +228 -175
  4. package/skills/nextjs-app-router/references/anti-patterns.md +163 -99
  5. package/skills/nextjs-app-router/references/folder-layout.md +79 -46
  6. package/skills/nextjs-app-router/references/good-patterns.md +233 -99
  7. package/skills/nextjs-app-router/references/templates/api-base.md +24 -21
  8. package/skills/nextjs-app-router/references/templates/auth-slice.md +82 -78
  9. package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +58 -6
  10. package/skills/nextjs-app-router/references/templates/db-client.md +48 -0
  11. package/skills/nextjs-app-router/references/templates/env-and-utils.md +90 -23
  12. package/skills/nextjs-app-router/references/templates/feature-slice.md +110 -47
  13. package/skills/nextjs-app-router/references/templates/form-with-zod.md +23 -15
  14. package/skills/nextjs-app-router/references/templates/middleware.md +69 -59
  15. package/skills/nextjs-app-router/references/templates/next-config.md +4 -14
  16. package/skills/nextjs-app-router/references/templates/nextauth-config.md +178 -0
  17. package/skills/nextjs-app-router/references/templates/package.md +35 -5
  18. package/skills/nextjs-app-router/references/templates/prisma-schema.md +162 -0
  19. package/skills/nextjs-app-router/references/templates/providers-and-store.md +35 -21
  20. package/skills/nextjs-app-router/references/templates/root-layout.md +99 -20
  21. package/skills/nextjs-app-router/references/templates/route-group-layouts.md +46 -6
  22. package/skills/nextjs-app-router/references/templates/route-handler.md +168 -0
  23. package/skills/nextjs-app-router/references/templates/testing.md +105 -31
@@ -1,95 +1,99 @@
1
- # Auth slice and auth API template
1
+ # NextAuth integration (replaces the old auth slice)
2
2
 
3
- ## `src/redux/features/authSlice.ts`
3
+ There is **no `authSlice.ts`** in this project. NextAuth's `SessionProvider` is the single source of session truth. This file documents how the pieces fit together so nobody is tempted to reintroduce a redundant Redux slice.
4
4
 
5
- ```ts
6
- import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
7
- import { authApi } from '@/redux/api/authApi';
8
-
9
- export interface AuthUser {
10
- id: string;
11
- email: string;
12
- name: string;
13
- roles: string[];
14
- }
5
+ ## Where session state lives
6
+
7
+ | Need | Source |
8
+ |------|--------|
9
+ | "Is the user signed in?" (client) | `useSession()` from `next-auth/react` |
10
+ | User id, email, role (client) | `useSession().data.user` |
11
+ | Server-side session inside a Route Handler | `await auth()` (or `requireSession()`) |
12
+ | Server-side session inside a server component (layouts only) | `await auth()` — **but layouts should not need this; middleware already gated** |
13
+ | Sign-in trigger | `signIn('credentials', { email, password, redirect: false })` from `next-auth/react` |
14
+ | Sign-out trigger | `signOut({ callbackUrl: '/auth/login', redirect: true })` |
15
+ | Auth gate at the network boundary | `middleware.ts` |
16
+
17
+ ## Sign-in (Credentials provider)
18
+
19
+ ```tsx
20
+ // src/app/(public)/auth/login/_components/LoginForm.tsx
21
+ 'use client';
15
22
 
16
- interface AuthState {
17
- user: AuthUser | null;
18
- token: string | null; // only populated for bearer-flow projects; null for httpOnly-cookie flows
19
- expiresAt: number | null;
23
+ import { signIn } from 'next-auth/react';
24
+ import { useRouter, useSearchParams } from 'next/navigation';
25
+
26
+ // inside onSubmit:
27
+ const res = await signIn('credentials', { email, password, redirect: false });
28
+ if (res?.error) {
29
+ // surface "Invalid email or password." — don't leak which field was wrong
30
+ return;
20
31
  }
32
+ router.replace(searchParams.get('returnTo') ?? '/app/dashboard');
33
+ router.refresh();
34
+ ```
21
35
 
22
- const initialState: AuthState = {
23
- user: null,
24
- token: null,
25
- expiresAt: null,
26
- };
36
+ ## Sign-out
37
+
38
+ ```tsx
39
+ 'use client';
40
+ import { signOut } from 'next-auth/react';
27
41
 
28
- export const authSlice = createSlice({
29
- name: 'auth',
30
- initialState,
31
- reducers: {
32
- logout: () => initialState,
33
- setUser: (state, action: PayloadAction<AuthUser>) => {
34
- state.user = action.payload;
35
- },
36
- },
37
- extraReducers: (builder) => {
38
- builder.addMatcher(authApi.endpoints.login.matchFulfilled, (state, { payload }) => {
39
- state.user = payload.user;
40
- state.token = payload.token ?? null;
41
- state.expiresAt = payload.expiresAt ?? null;
42
- });
43
- builder.addMatcher(authApi.endpoints.getMe.matchFulfilled, (state, { payload }) => {
44
- state.user = payload;
45
- });
46
- },
47
- });
48
-
49
- export const { logout, setUser } = authSlice.actions;
42
+ <Button onClick={() => signOut({ callbackUrl: '/auth/login', redirect: true })}>
43
+ Sign out
44
+ </Button>
50
45
  ```
51
46
 
52
- **Note:** the store's `rootReducer` already wipes the entire store on `authApi.endpoints.logout.matchFulfilled` — so a `logout()` action dispatched here from the 401 handler still triggers a full state reset via the wrapped reducer in `store.ts`. The slice's own `logout` reducer is there for cases where the user explicitly clicks "Sign out" without hitting the logout endpoint.
47
+ The Redux store's `signOutEvent` is also dispatched from the RTK Query base on 401 (see [`api-base.md`](api-base.md)) to clear cached queries. For a user-initiated sign-out, that happens implicitly when the next request 401s but if you want to clear the cache immediately on click, dispatch `signOutEvent` before calling `signOut`:
53
48
 
54
- ## `src/redux/api/authApi.ts`
49
+ ```tsx
50
+ import { useDispatch } from 'react-redux';
51
+ import { signOutEvent } from '@/redux/store';
52
+
53
+ const dispatch = useDispatch();
54
+ const onSignOut = () => {
55
+ dispatch(signOutEvent);
56
+ void signOut({ callbackUrl: '/auth/login', redirect: true });
57
+ };
58
+ ```
59
+
60
+ ## Module augmentation — `src/types/next-auth.d.ts`
61
+
62
+ Without this, `session.user.id` is `undefined` in TypeScript even though the runtime callback puts it there.
55
63
 
56
64
  ```ts
57
- import { api } from './api';
58
- import type { AuthUser } from '@/redux/features/authSlice';
65
+ import 'next-auth';
66
+ import 'next-auth/jwt';
59
67
 
60
- export interface LoginRequest {
61
- email: string;
62
- password: string;
63
- }
68
+ declare module 'next-auth' {
69
+ interface Session {
70
+ user: {
71
+ id: string;
72
+ email: string;
73
+ name?: string | null;
74
+ image?: string | null;
75
+ role?: string;
76
+ };
77
+ }
64
78
 
65
- export interface LoginResponse {
66
- user: AuthUser;
67
- /** Populated only for bearer-token flows. httpOnly-cookie flows leave this undefined. */
68
- token?: string;
69
- expiresAt?: number;
79
+ interface User {
80
+ id: string;
81
+ role?: string;
82
+ }
70
83
  }
71
84
 
72
- export const authApi = api.injectEndpoints({
73
- endpoints: (build) => ({
74
- login: build.mutation<LoginResponse, LoginRequest>({
75
- query: (body) => ({ url: '/auth/login', method: 'POST', body }),
76
- invalidatesTags: ['Auth'],
77
- }),
78
- logout: build.mutation<void, void>({
79
- query: () => ({ url: '/auth/logout', method: 'POST' }),
80
- invalidatesTags: ['Auth'],
81
- }),
82
- getMe: build.query<AuthUser, void>({
83
- query: () => '/auth/me',
84
- providesTags: ['Auth'],
85
- }),
86
- }),
87
- });
88
-
89
- export const { useLoginMutation, useLogoutMutation, useGetMeQuery } = authApi;
85
+ declare module 'next-auth/jwt' {
86
+ interface JWT {
87
+ id: string;
88
+ role?: string;
89
+ }
90
+ }
90
91
  ```
91
92
 
92
- **Forbidden:**
93
- - Storing the token via `js-cookie` inside the slice (`Cookies.set('token', ...)`). For `httpOnly` flows, the backend sets the cookie; for bearer flows, the token lives in Redux state and the `prepareHeaders` callback attaches it. Never both.
94
- - Base64-encoding the user and writing it to a cookie (`Cookies.set('user', btoa(JSON.stringify(...)))`). It's not encryption, it's obfuscation. If the server needs the user, the server reads it from the session cookie / database.
95
- - Decoding the JWT on the client to drive auth decisions. Decode only for non-sensitive display (e.g. showing the user's name pulled from a claim) never as the gate. The gate is the server.
93
+ ## Forbidden
94
+
95
+ - A Redux `authSlice` storing `user`, `token`, or `expiresAt`. Use `useSession()`.
96
+ - Manual `js-cookie` writes (`Cookies.set('token', ...)`). NextAuth owns the session cookie and it's `httpOnly` by design.
97
+ - Decoding the NextAuth JWT on the client. Use the hook.
98
+ - Calling `await auth()` inside a server `layout.tsx` "just to check". Middleware already gated. Calling `auth()` again is a round-trip with no behavior change.
99
+ - A second auth library (`Clerk`, `Lucia`, custom JWT signer) running alongside NextAuth.
@@ -18,6 +18,27 @@ jobs:
18
18
  build:
19
19
  runs-on: ubuntu-latest
20
20
  timeout-minutes: 15
21
+
22
+ services:
23
+ postgres:
24
+ image: postgres:16
25
+ env:
26
+ POSTGRES_USER: postgres
27
+ POSTGRES_PASSWORD: postgres
28
+ POSTGRES_DB: {{project_db}}_test
29
+ ports:
30
+ - 5432:5432
31
+ options: >-
32
+ --health-cmd "pg_isready -U postgres"
33
+ --health-interval 10s
34
+ --health-timeout 5s
35
+ --health-retries 5
36
+
37
+ env:
38
+ DATABASE_URL: postgresql://postgres:postgres@localhost:5432/{{project_db}}_test
39
+ AUTH_SECRET: ci-only-secret-replace-in-prod-1234567890abcdef
40
+ AUTH_URL: http://localhost:3000
41
+
21
42
  steps:
22
43
  - uses: actions/checkout@v4
23
44
 
@@ -32,12 +53,14 @@ jobs:
32
53
 
33
54
  - run: pnpm install --frozen-lockfile
34
55
 
56
+ # Apply committed migrations against the CI Postgres. Never `prisma db push` here.
57
+ - run: pnpm exec prisma migrate deploy
58
+ - run: pnpm exec prisma generate
59
+
35
60
  - run: pnpm lint --max-warnings=0
36
61
  - run: pnpm typecheck
37
62
  - run: pnpm test
38
63
  - run: pnpm build
39
- env:
40
- NEXT_PUBLIC_API_BASE_URL: http://localhost:5000
41
64
 
42
65
  # Optional separate job for E2E — only run on push to main to save CI minutes.
43
66
  e2e:
@@ -45,6 +68,27 @@ jobs:
45
68
  runs-on: ubuntu-latest
46
69
  timeout-minutes: 20
47
70
  needs: build
71
+
72
+ services:
73
+ postgres:
74
+ image: postgres:16
75
+ env:
76
+ POSTGRES_USER: postgres
77
+ POSTGRES_PASSWORD: postgres
78
+ POSTGRES_DB: {{project_db}}_e2e
79
+ ports:
80
+ - 5432:5432
81
+ options: >-
82
+ --health-cmd "pg_isready -U postgres"
83
+ --health-interval 10s
84
+ --health-timeout 5s
85
+ --health-retries 5
86
+
87
+ env:
88
+ DATABASE_URL: postgresql://postgres:postgres@localhost:5432/{{project_db}}_e2e
89
+ AUTH_SECRET: ci-only-secret-replace-in-prod-1234567890abcdef
90
+ AUTH_URL: http://localhost:3000
91
+
48
92
  steps:
49
93
  - uses: actions/checkout@v4
50
94
  - uses: pnpm/action-setup@v4
@@ -52,16 +96,22 @@ jobs:
52
96
  - uses: actions/setup-node@v4
53
97
  with: { node-version: 20, cache: pnpm }
54
98
  - run: pnpm install --frozen-lockfile
99
+ - run: pnpm exec prisma migrate deploy
100
+ - run: pnpm exec prisma generate
101
+ - run: pnpm db:seed
55
102
  - run: pnpm exec playwright install --with-deps chromium
56
103
  - run: pnpm test:e2e
57
- env:
58
- NEXT_PUBLIC_API_BASE_URL: http://localhost:5000
59
104
  ```
60
105
 
61
106
  **Notes:**
107
+
62
108
  - `--frozen-lockfile` ensures the lockfile is honored — CI fails if `package.json` and lockfile drift.
63
109
  - `--max-warnings=0` on lint means even warnings fail CI. On a fresh scaffold there are none; this catches regressions early.
64
- - E2E is split into its own job behind `if: github.event_name == 'push'` so PRs stay fast.
110
+ - `AUTH_SECRET` in CI is a throwaway. **Do not** reuse it in production. The placeholder above is long enough to satisfy the `z.string().min(32)` check in `src/config/env.ts`.
111
+ - `prisma migrate deploy` applies committed migrations. **Never** `prisma db push` in CI — it bypasses the migration history.
112
+ - `prisma generate` runs after `migrate deploy` so `@prisma/client` matches the schema. (`migrate deploy` does not regenerate the client; `migrate dev` does, but `migrate dev` is interactive and not for CI.)
113
+ - The E2E job seeds a test user via `pnpm db:seed` so the Credentials login flow has something to authenticate against.
114
+ - E2E is gated on `github.event_name == 'push'` so PRs stay fast.
65
115
 
66
116
  ## Husky + lint-staged
67
117
 
@@ -85,10 +135,12 @@ The `lint-staged` config lives in `package.json` (see [`package.md`](package.md)
85
135
  ```jsonc
86
136
  "lint-staged": {
87
137
  "*.{ts,tsx}": ["prettier --write", "eslint --fix"],
88
- "*.{json,md,css,yml,yaml}": ["prettier --write"]
138
+ "*.{json,md,css,yml,yaml,prisma}": ["prettier --write"]
89
139
  }
90
140
  ```
91
141
 
92
142
  **Notes:**
143
+
93
144
  - Do not put `tsc --noEmit` in lint-staged — it's per-staged-file and TypeScript is project-wide; let CI handle it.
94
145
  - Do not skip hooks (`--no-verify`) as a workflow. If a hook is annoying, fix the underlying issue.
146
+ - `prettier-plugin-prisma` (auto-loaded when present in deps) formats `schema.prisma` on commit.
@@ -0,0 +1,48 @@
1
+ # Prisma client (singleton)
2
+
3
+ ## `src/lib/db.ts`
4
+
5
+ ```ts
6
+ import { PrismaClient } from '@prisma/client';
7
+
8
+ const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
9
+
10
+ export const db =
11
+ globalForPrisma.prisma ??
12
+ new PrismaClient({
13
+ log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
14
+ });
15
+
16
+ if (process.env.NODE_ENV !== 'production') {
17
+ globalForPrisma.prisma = db;
18
+ }
19
+ ```
20
+
21
+ ## Why a singleton
22
+
23
+ Next.js dev hot-reload re-evaluates modules on every change. Without the global cache, each reload constructs a fresh `PrismaClient`, opens a new connection pool, and never closes the old one — within minutes of editing you exhaust your Postgres `max_connections` and start seeing `FATAL: sorry, too many clients already`.
24
+
25
+ The pattern above:
26
+ - In **production**, behaves like a normal module-scoped singleton (one client per process).
27
+ - In **dev**, attaches the client to `globalThis` so hot-reloads reuse it.
28
+
29
+ ## Forbidden
30
+
31
+ - `import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient()` anywhere outside this file. Every consumer imports `db` from `@/lib/db`.
32
+ - Importing `db` from `src/auth.config.ts` (Edge runtime — can't import Prisma).
33
+ - Importing `db` from any `src/app/**` file outside `route.ts` files. Server pages and layouts do not touch the database.
34
+ - Calling `db.$connect()` / `db.$disconnect()` manually in handlers. Prisma manages the pool. The only place a manual `$disconnect()` is appropriate is at the bottom of `prisma/seed.ts`.
35
+
36
+ ## Where `db` is allowed to be imported
37
+
38
+ | File pattern | Allowed? |
39
+ |---|---|
40
+ | `src/app/api/**/route.ts` | ✅ Yes |
41
+ | `src/auth.ts` | ✅ Yes (Credentials `authorize` reads user table) |
42
+ | `prisma/seed.ts` | ✅ Yes (uses `new PrismaClient()` directly; doesn't share with the app) |
43
+ | `src/lib/*.ts` (server-only helpers) | ✅ Yes |
44
+ | `src/auth.config.ts` | ❌ No — Edge runtime |
45
+ | `middleware.ts` | ❌ No — Edge runtime |
46
+ | `src/app/**/page.tsx` / `layout.tsx` (any non-`route.ts`) | ❌ No — pages/layouts don't fetch data; RTK Query → /api → db |
47
+ | `src/redux/**` | ❌ No — Redux runs in the browser |
48
+ | `src/components/**` | ❌ No |
@@ -1,4 +1,4 @@
1
- # Env validation, `cn()`, and shared utils
1
+ # Env validation, `cn()`, shared utils
2
2
 
3
3
  ## `src/config/env.ts` (runtime-validated env)
4
4
 
@@ -6,16 +6,23 @@
6
6
  import { z } from 'zod';
7
7
 
8
8
  const envSchema = z.object({
9
- NEXT_PUBLIC_API_BASE_URL: z.string().url(),
10
- // Add more vars here as needed. Keep client-readable ones prefixed with NEXT_PUBLIC_.
9
+ // NextAuth — required.
10
+ AUTH_SECRET: z.string().min(32, 'AUTH_SECRET must be at least 32 chars. Generate with: openssl rand -base64 32'),
11
+ AUTH_URL: z.string().url().optional(), // required in prod, derived locally
12
+
13
+ // Database — required.
14
+ DATABASE_URL: z.string().url(),
15
+
16
+ // OAuth providers — required ONLY if the matching provider is enabled in auth.config.ts.
17
+ AUTH_GITHUB_ID: z.string().optional(),
18
+ AUTH_GITHUB_SECRET: z.string().optional(),
19
+ AUTH_GOOGLE_ID: z.string().optional(),
20
+ AUTH_GOOGLE_SECRET: z.string().optional(),
11
21
  });
12
22
 
13
23
  function loadEnv() {
14
- const parsed = envSchema.safeParse({
15
- NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
16
- });
24
+ const parsed = envSchema.safeParse(process.env);
17
25
  if (!parsed.success) {
18
- // Fail fast at module load.
19
26
  console.error('Invalid environment configuration:', parsed.error.flatten().fieldErrors);
20
27
  throw new Error('Invalid environment configuration. See .env.example for required variables.');
21
28
  }
@@ -25,23 +32,31 @@ function loadEnv() {
25
32
  export const env = loadEnv();
26
33
  ```
27
34
 
28
- **Why:** misconfiguration (`NEXT_PUBLIC_API_BASE_URL` undefined, typo'd) blows up at startup with a clear error, not via a confusing 404 on the first network request.
35
+ **Why:** misconfiguration (missing `AUTH_SECRET`, malformed `DATABASE_URL`) blows up at startup with a clear error, not via a confusing 500 on the first auth check or DB query.
29
36
 
30
- ## `.env.example` (committed)
37
+ ## `.env.example` (committed — NO secret values)
31
38
 
32
- ```
33
- # Backend API base URL. Required.
34
- NEXT_PUBLIC_API_BASE_URL=http://localhost:5000
39
+ ```dotenv
40
+ # ---- NextAuth ----
41
+ # Generate with: openssl rand -base64 32 (or `npx auth secret`)
42
+ # REQUIRED. Do NOT commit a real value.
43
+ AUTH_SECRET=
44
+
45
+ # Public URL of the app. Required in production. Optional locally (Auth.js derives from the request).
46
+ # AUTH_URL=https://example.com
35
47
 
36
- # Optional: real-time
37
- # NEXT_PUBLIC_PUSHER_KEY=
38
- # NEXT_PUBLIC_PUSHER_CLUSTER=
48
+ # ---- Database ----
49
+ # Required. For local Docker: postgresql://postgres:postgres@localhost:5432/{{project_db}}
50
+ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/{{project_db}}
39
51
 
40
- # Server-only: JWT verification key (only for Variant B middleware).
41
- # JWT_VERIFY_KEY=
52
+ # ---- OAuth providers (only if enabled in src/auth.config.ts) ----
53
+ # AUTH_GITHUB_ID=
54
+ # AUTH_GITHUB_SECRET=
55
+ # AUTH_GOOGLE_ID=
56
+ # AUTH_GOOGLE_SECRET=
42
57
  ```
43
58
 
44
- `.env`, `.env.local`, `.env.*.local` go in `.gitignore`. Only `.env.example` is committed.
59
+ `.env`, `.env.local`, `.env.*.local` go in `.gitignore`. Only `.env.example` is committed. **`AUTH_SECRET` must never have a real value in `.env.example`** — leave it empty with a comment showing how to generate one.
45
60
 
46
61
  ## `src/lib/utils.ts`
47
62
 
@@ -56,6 +71,61 @@ export function cn(...inputs: ClassValue[]): string {
56
71
 
57
72
  That's the entire file. No bag of unrelated helpers — those go in `src/lib/formatters/` or feature-local utility files.
58
73
 
74
+ ## `src/lib/api-auth.ts` (Route Handler auth helper)
75
+
76
+ ```ts
77
+ import { NextResponse } from 'next/server';
78
+ import { auth } from '@/auth';
79
+
80
+ export class HttpError extends Error {
81
+ constructor(public status: number, public body: unknown) {
82
+ super(typeof body === 'string' ? body : JSON.stringify(body));
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Returns the current session, or throws an HttpError(401) that the wrapping
88
+ * handler converts to a 401 JSON response. Use inside Route Handlers.
89
+ */
90
+ export async function requireSession() {
91
+ const session = await auth();
92
+ if (!session?.user?.id) {
93
+ throw new HttpError(401, { error: 'Unauthorized' });
94
+ }
95
+ return session as typeof session & { user: { id: string; email: string; role?: string } };
96
+ }
97
+
98
+ /**
99
+ * Wraps a Route Handler so that thrown HttpErrors become JSON responses.
100
+ * Optional — handlers can also try/catch themselves.
101
+ */
102
+ export function withApiErrors<T extends (...args: never[]) => Promise<Response | NextResponse>>(
103
+ fn: T,
104
+ ): T {
105
+ return (async (...args: Parameters<T>) => {
106
+ try {
107
+ return await fn(...args);
108
+ } catch (err) {
109
+ if (err instanceof HttpError) {
110
+ return NextResponse.json(err.body, { status: err.status });
111
+ }
112
+ console.error('Unhandled handler error:', err);
113
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
114
+ }
115
+ }) as T;
116
+ }
117
+
118
+ /**
119
+ * Asserts that a fetched row belongs to the current user. Returns the row.
120
+ */
121
+ export function assertOwnership<T extends { userId: string }>(row: T | null, userId: string): T {
122
+ if (!row || row.userId !== userId) {
123
+ throw new HttpError(404, { error: 'Not found' });
124
+ }
125
+ return row;
126
+ }
127
+ ```
128
+
59
129
  ## `src/lib/formatters/date.ts`
60
130
 
61
131
  ```ts
@@ -99,12 +169,9 @@ export function UnsavedChangesWarning({ when, message = 'You have unsaved change
99
169
  return () => window.removeEventListener('beforeunload', onBeforeUnload);
100
170
  }, [when, message]);
101
171
 
102
- // Next's App Router does not yet expose a stable router-events API for intercepting client-side navigation.
103
- // The pattern below (overriding history.pushState) is a workaround review and adjust per the Next version
104
- // resolved at scaffold time (use context7 to check whether the official `useBeforeUnload` / `unstable_*` API has landed).
172
+ // Next App Router does not yet expose a stable router-events API for intercepting client-side navigation.
173
+ // Verify at scaffold time via context7 whether a stable hook has landed; if so, use it here in addition to beforeunload.
105
174
 
106
175
  return null;
107
176
  }
108
177
  ```
109
-
110
- **Adjust at scaffold time:** if the Next.js version resolved in Step 1 exposes a stable navigation-intercept hook, use it instead of the `beforeunload`-only fallback. Verify via context7.