@malamute/ai-rules 1.0.0 → 1.3.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 +272 -121
- package/bin/cli.js +5 -2
- package/configs/_shared/CLAUDE.md +52 -149
- package/configs/_shared/rules/conventions/documentation.md +324 -0
- package/configs/_shared/rules/conventions/git.md +265 -0
- package/configs/_shared/rules/conventions/npm.md +80 -0
- package/configs/_shared/{.claude/rules → rules/conventions}/performance.md +1 -1
- package/configs/_shared/rules/conventions/principles.md +334 -0
- package/configs/_shared/rules/devops/ci-cd.md +262 -0
- package/configs/_shared/rules/devops/docker.md +275 -0
- package/configs/_shared/rules/devops/nx.md +194 -0
- package/configs/_shared/rules/domain/backend/api-design.md +203 -0
- package/configs/_shared/rules/lang/csharp/async.md +220 -0
- package/configs/_shared/rules/lang/csharp/csharp.md +314 -0
- package/configs/_shared/rules/lang/csharp/linq.md +210 -0
- package/configs/_shared/rules/lang/python/async.md +337 -0
- package/configs/_shared/rules/lang/python/celery.md +476 -0
- package/configs/_shared/rules/lang/python/config.md +339 -0
- package/configs/{python/.claude/rules → _shared/rules/lang/python}/database/sqlalchemy.md +6 -1
- package/configs/_shared/rules/lang/python/deployment.md +523 -0
- package/configs/_shared/rules/lang/python/error-handling.md +330 -0
- package/configs/_shared/rules/lang/python/migrations.md +421 -0
- package/configs/_shared/rules/lang/python/python.md +172 -0
- package/configs/_shared/rules/lang/python/repository.md +383 -0
- package/configs/{python/.claude/rules → _shared/rules/lang/python}/testing.md +2 -69
- package/configs/_shared/rules/lang/typescript/async.md +447 -0
- package/configs/_shared/rules/lang/typescript/generics.md +356 -0
- package/configs/_shared/rules/lang/typescript/typescript.md +212 -0
- package/configs/_shared/rules/quality/error-handling.md +48 -0
- package/configs/_shared/rules/quality/logging.md +45 -0
- package/configs/_shared/rules/quality/observability.md +240 -0
- package/configs/_shared/rules/quality/testing-patterns.md +65 -0
- package/configs/_shared/rules/security/secrets-management.md +222 -0
- package/configs/_shared/skills/analysis/explore/SKILL.md +257 -0
- package/configs/_shared/skills/analysis/security-audit/SKILL.md +184 -0
- package/configs/_shared/skills/dev/api-endpoint/SKILL.md +126 -0
- package/configs/_shared/{.claude/commands/generate-tests.md → skills/dev/generate-tests/SKILL.md} +6 -0
- package/configs/_shared/{.claude/commands/fix-issue.md → skills/git/fix-issue/SKILL.md} +6 -0
- package/configs/_shared/{.claude/commands/review-pr.md → skills/git/review-pr/SKILL.md} +6 -0
- package/configs/_shared/skills/infra/deploy/SKILL.md +139 -0
- package/configs/_shared/skills/infra/docker/SKILL.md +95 -0
- package/configs/_shared/skills/infra/migration/SKILL.md +158 -0
- package/configs/_shared/skills/nx/nx-affected/SKILL.md +72 -0
- package/configs/_shared/skills/nx/nx-lib/SKILL.md +375 -0
- package/configs/angular/CLAUDE.md +24 -216
- package/configs/angular/{.claude/rules → rules/core}/components.md +69 -15
- package/configs/angular/rules/core/resource.md +285 -0
- package/configs/angular/rules/core/signals.md +323 -0
- package/configs/angular/rules/http.md +338 -0
- package/configs/angular/rules/routing.md +291 -0
- package/configs/angular/rules/ssr.md +312 -0
- package/configs/angular/rules/state/signal-store.md +408 -0
- package/configs/angular/{.claude/rules → rules/state}/state.md +2 -2
- package/configs/angular/{.claude/rules → rules}/testing.md +7 -7
- package/configs/angular/rules/ui/aria.md +422 -0
- package/configs/angular/rules/ui/forms.md +424 -0
- package/configs/angular/rules/ui/pipes-directives.md +335 -0
- package/configs/angular/{.claude/settings.json → settings.json} +3 -0
- package/configs/dotnet/CLAUDE.md +53 -286
- package/configs/dotnet/rules/background-services.md +552 -0
- package/configs/dotnet/rules/configuration.md +426 -0
- package/configs/dotnet/rules/ddd.md +447 -0
- package/configs/dotnet/rules/dependency-injection.md +343 -0
- package/configs/dotnet/rules/mediatr.md +320 -0
- package/configs/dotnet/rules/middleware.md +489 -0
- package/configs/dotnet/rules/result-pattern.md +363 -0
- package/configs/dotnet/rules/validation.md +388 -0
- package/configs/dotnet/settings.json +29 -0
- package/configs/fastapi/CLAUDE.md +144 -0
- package/configs/fastapi/rules/background-tasks.md +254 -0
- package/configs/fastapi/rules/dependencies.md +170 -0
- package/configs/{python/.claude → fastapi}/rules/fastapi.md +61 -1
- package/configs/fastapi/rules/lifespan.md +274 -0
- package/configs/fastapi/rules/middleware.md +229 -0
- package/configs/fastapi/rules/pydantic.md +433 -0
- package/configs/fastapi/rules/responses.md +251 -0
- package/configs/fastapi/rules/routers.md +202 -0
- package/configs/fastapi/rules/security.md +222 -0
- package/configs/fastapi/rules/testing.md +251 -0
- package/configs/fastapi/rules/websockets.md +298 -0
- package/configs/fastapi/settings.json +35 -0
- package/configs/flask/CLAUDE.md +166 -0
- package/configs/flask/rules/blueprints.md +208 -0
- package/configs/flask/rules/cli.md +285 -0
- package/configs/flask/rules/configuration.md +281 -0
- package/configs/flask/rules/context.md +238 -0
- package/configs/flask/rules/error-handlers.md +278 -0
- package/configs/flask/rules/extensions.md +278 -0
- package/configs/flask/rules/flask.md +171 -0
- package/configs/flask/rules/marshmallow.md +206 -0
- package/configs/flask/rules/security.md +267 -0
- package/configs/flask/rules/testing.md +284 -0
- package/configs/flask/settings.json +35 -0
- package/configs/nestjs/CLAUDE.md +57 -215
- package/configs/nestjs/rules/common-patterns.md +300 -0
- package/configs/nestjs/rules/filters.md +376 -0
- package/configs/nestjs/rules/interceptors.md +317 -0
- package/configs/nestjs/rules/middleware.md +321 -0
- package/configs/nestjs/{.claude/rules → rules}/modules.md +26 -0
- package/configs/nestjs/rules/pipes.md +351 -0
- package/configs/nestjs/rules/websockets.md +451 -0
- package/configs/nestjs/settings.json +31 -0
- package/configs/nextjs/CLAUDE.md +69 -331
- package/configs/nextjs/rules/api-routes.md +358 -0
- package/configs/nextjs/rules/authentication.md +355 -0
- package/configs/nextjs/{.claude/rules → rules}/components.md +52 -0
- package/configs/nextjs/rules/data-fetching.md +249 -0
- package/configs/nextjs/rules/database.md +400 -0
- package/configs/nextjs/rules/middleware.md +303 -0
- package/configs/nextjs/rules/routing.md +324 -0
- package/configs/nextjs/rules/seo.md +350 -0
- package/configs/nextjs/rules/server-actions.md +353 -0
- package/configs/nextjs/{.claude/rules → rules}/state/zustand.md +6 -6
- package/configs/nextjs/{.claude/settings.json → settings.json} +7 -0
- package/package.json +24 -9
- package/src/cli.js +218 -0
- package/src/config.js +63 -0
- package/src/index.js +4 -0
- package/src/installer.js +414 -0
- package/src/merge.js +109 -0
- package/src/tech-config.json +45 -0
- package/src/utils.js +88 -0
- package/configs/dotnet/.claude/settings.json +0 -9
- package/configs/nestjs/.claude/settings.json +0 -15
- package/configs/python/.claude/rules/flask.md +0 -332
- package/configs/python/.claude/settings.json +0 -18
- package/configs/python/CLAUDE.md +0 -273
- package/src/install.js +0 -315
- /package/configs/_shared/{.claude/rules → rules/domain/frontend}/accessibility.md +0 -0
- /package/configs/_shared/{.claude/rules → rules/security}/security.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/dev}/debug/SKILL.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/dev}/learning/SKILL.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/dev}/spec/SKILL.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/git}/review/SKILL.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/api.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/architecture.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/database/efcore.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/testing.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/auth.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/database/prisma.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/database/typeorm.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/testing.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/validation.md +0 -0
- /package/configs/nextjs/{.claude/rules → rules}/state/redux-toolkit.md +0 -0
- /package/configs/nextjs/{.claude/rules → rules}/testing.md +0 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/layout.tsx"
|
|
4
|
+
- "**/page.tsx"
|
|
5
|
+
- "app/sitemap.ts"
|
|
6
|
+
- "app/robots.ts"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Next.js SEO
|
|
10
|
+
|
|
11
|
+
## Metadata API
|
|
12
|
+
|
|
13
|
+
### Static Metadata
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// app/page.tsx
|
|
17
|
+
import type { Metadata } from 'next';
|
|
18
|
+
|
|
19
|
+
export const metadata: Metadata = {
|
|
20
|
+
title: 'Home | My App',
|
|
21
|
+
description: 'Welcome to my application',
|
|
22
|
+
keywords: ['next.js', 'react', 'web development'],
|
|
23
|
+
authors: [{ name: 'John Doe' }],
|
|
24
|
+
openGraph: {
|
|
25
|
+
title: 'Home | My App',
|
|
26
|
+
description: 'Welcome to my application',
|
|
27
|
+
url: 'https://example.com',
|
|
28
|
+
siteName: 'My App',
|
|
29
|
+
images: [
|
|
30
|
+
{
|
|
31
|
+
url: 'https://example.com/og-image.png',
|
|
32
|
+
width: 1200,
|
|
33
|
+
height: 630,
|
|
34
|
+
alt: 'My App',
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
locale: 'en_US',
|
|
38
|
+
type: 'website',
|
|
39
|
+
},
|
|
40
|
+
twitter: {
|
|
41
|
+
card: 'summary_large_image',
|
|
42
|
+
title: 'Home | My App',
|
|
43
|
+
description: 'Welcome to my application',
|
|
44
|
+
images: ['https://example.com/og-image.png'],
|
|
45
|
+
},
|
|
46
|
+
robots: {
|
|
47
|
+
index: true,
|
|
48
|
+
follow: true,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Dynamic Metadata
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
// app/blog/[slug]/page.tsx
|
|
57
|
+
import type { Metadata } from 'next';
|
|
58
|
+
|
|
59
|
+
type Props = {
|
|
60
|
+
params: Promise<{ slug: string }>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
64
|
+
const { slug } = await params;
|
|
65
|
+
const post = await getPost(slug);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
title: `${post.title} | Blog`,
|
|
69
|
+
description: post.excerpt,
|
|
70
|
+
openGraph: {
|
|
71
|
+
title: post.title,
|
|
72
|
+
description: post.excerpt,
|
|
73
|
+
type: 'article',
|
|
74
|
+
publishedTime: post.publishedAt,
|
|
75
|
+
authors: [post.author.name],
|
|
76
|
+
images: [
|
|
77
|
+
{
|
|
78
|
+
url: post.featuredImage,
|
|
79
|
+
width: 1200,
|
|
80
|
+
height: 630,
|
|
81
|
+
alt: post.title,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Layout Metadata (Template)
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// app/layout.tsx
|
|
93
|
+
import type { Metadata } from 'next';
|
|
94
|
+
|
|
95
|
+
export const metadata: Metadata = {
|
|
96
|
+
metadataBase: new URL('https://example.com'),
|
|
97
|
+
title: {
|
|
98
|
+
template: '%s | My App',
|
|
99
|
+
default: 'My App',
|
|
100
|
+
},
|
|
101
|
+
description: 'My application description',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Child page: title: 'Blog' → renders as 'Blog | My App'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Sitemap
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// app/sitemap.ts
|
|
111
|
+
import type { MetadataRoute } from 'next';
|
|
112
|
+
|
|
113
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
114
|
+
const baseUrl = 'https://example.com';
|
|
115
|
+
|
|
116
|
+
// Static pages
|
|
117
|
+
const staticPages = [
|
|
118
|
+
{ url: baseUrl, lastModified: new Date(), changeFrequency: 'daily' as const, priority: 1 },
|
|
119
|
+
{ url: `${baseUrl}/about`, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 0.8 },
|
|
120
|
+
{ url: `${baseUrl}/contact`, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 0.5 },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
// Dynamic pages
|
|
124
|
+
const posts = await db.post.findMany({
|
|
125
|
+
select: { slug: true, updatedAt: true },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const postPages = posts.map(post => ({
|
|
129
|
+
url: `${baseUrl}/blog/${post.slug}`,
|
|
130
|
+
lastModified: post.updatedAt,
|
|
131
|
+
changeFrequency: 'weekly' as const,
|
|
132
|
+
priority: 0.7,
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
return [...staticPages, ...postPages];
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Large Sitemap (Multiple Files)
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// app/sitemap/[id]/route.ts
|
|
143
|
+
import { getPostsBatch } from '@/lib/posts';
|
|
144
|
+
|
|
145
|
+
export async function GET(
|
|
146
|
+
request: Request,
|
|
147
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
148
|
+
) {
|
|
149
|
+
const { id } = await params;
|
|
150
|
+
const posts = await getPostsBatch(parseInt(id));
|
|
151
|
+
|
|
152
|
+
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
|
153
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
154
|
+
${posts.map(post => `
|
|
155
|
+
<url>
|
|
156
|
+
<loc>https://example.com/blog/${post.slug}</loc>
|
|
157
|
+
<lastmod>${post.updatedAt.toISOString()}</lastmod>
|
|
158
|
+
</url>
|
|
159
|
+
`).join('')}
|
|
160
|
+
</urlset>`;
|
|
161
|
+
|
|
162
|
+
return new Response(sitemap, {
|
|
163
|
+
headers: { 'Content-Type': 'application/xml' },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Robots.txt
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// app/robots.ts
|
|
172
|
+
import type { MetadataRoute } from 'next';
|
|
173
|
+
|
|
174
|
+
export default function robots(): MetadataRoute.Robots {
|
|
175
|
+
return {
|
|
176
|
+
rules: [
|
|
177
|
+
{
|
|
178
|
+
userAgent: '*',
|
|
179
|
+
allow: '/',
|
|
180
|
+
disallow: ['/admin/', '/api/', '/private/'],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
userAgent: 'Googlebot',
|
|
184
|
+
allow: '/',
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
sitemap: 'https://example.com/sitemap.xml',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## JSON-LD Structured Data
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// components/structured-data.tsx
|
|
196
|
+
export function ArticleJsonLd({
|
|
197
|
+
title,
|
|
198
|
+
description,
|
|
199
|
+
publishedTime,
|
|
200
|
+
author,
|
|
201
|
+
image,
|
|
202
|
+
url,
|
|
203
|
+
}: ArticleJsonLdProps) {
|
|
204
|
+
const jsonLd = {
|
|
205
|
+
'@context': 'https://schema.org',
|
|
206
|
+
'@type': 'Article',
|
|
207
|
+
headline: title,
|
|
208
|
+
description,
|
|
209
|
+
image,
|
|
210
|
+
datePublished: publishedTime,
|
|
211
|
+
author: {
|
|
212
|
+
'@type': 'Person',
|
|
213
|
+
name: author,
|
|
214
|
+
},
|
|
215
|
+
publisher: {
|
|
216
|
+
'@type': 'Organization',
|
|
217
|
+
name: 'My App',
|
|
218
|
+
logo: {
|
|
219
|
+
'@type': 'ImageObject',
|
|
220
|
+
url: 'https://example.com/logo.png',
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
mainEntityOfPage: url,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<script
|
|
228
|
+
type="application/ld+json"
|
|
229
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
230
|
+
/>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Usage in page
|
|
235
|
+
export default function BlogPost({ post }) {
|
|
236
|
+
return (
|
|
237
|
+
<>
|
|
238
|
+
<ArticleJsonLd
|
|
239
|
+
title={post.title}
|
|
240
|
+
description={post.excerpt}
|
|
241
|
+
publishedTime={post.publishedAt}
|
|
242
|
+
author={post.author.name}
|
|
243
|
+
image={post.featuredImage}
|
|
244
|
+
url={`https://example.com/blog/${post.slug}`}
|
|
245
|
+
/>
|
|
246
|
+
<article>...</article>
|
|
247
|
+
</>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Organization Schema
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
export function OrganizationJsonLd() {
|
|
256
|
+
const jsonLd = {
|
|
257
|
+
'@context': 'https://schema.org',
|
|
258
|
+
'@type': 'Organization',
|
|
259
|
+
name: 'My Company',
|
|
260
|
+
url: 'https://example.com',
|
|
261
|
+
logo: 'https://example.com/logo.png',
|
|
262
|
+
sameAs: [
|
|
263
|
+
'https://twitter.com/mycompany',
|
|
264
|
+
'https://linkedin.com/company/mycompany',
|
|
265
|
+
],
|
|
266
|
+
contactPoint: {
|
|
267
|
+
'@type': 'ContactPoint',
|
|
268
|
+
telephone: '+1-800-555-1234',
|
|
269
|
+
contactType: 'customer service',
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<script
|
|
275
|
+
type="application/ld+json"
|
|
276
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
277
|
+
/>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Canonical URLs
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
export const metadata: Metadata = {
|
|
286
|
+
alternates: {
|
|
287
|
+
canonical: 'https://example.com/page',
|
|
288
|
+
languages: {
|
|
289
|
+
'en-US': 'https://example.com/en-US/page',
|
|
290
|
+
'fr-FR': 'https://example.com/fr-FR/page',
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Performance (Core Web Vitals)
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
// next.config.js
|
|
300
|
+
module.exports = {
|
|
301
|
+
images: {
|
|
302
|
+
formats: ['image/avif', 'image/webp'],
|
|
303
|
+
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
|
304
|
+
},
|
|
305
|
+
experimental: {
|
|
306
|
+
optimizeCss: true,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Image component
|
|
311
|
+
import Image from 'next/image';
|
|
312
|
+
|
|
313
|
+
<Image
|
|
314
|
+
src="/hero.jpg"
|
|
315
|
+
alt="Hero image"
|
|
316
|
+
width={1200}
|
|
317
|
+
height={630}
|
|
318
|
+
priority // For LCP images
|
|
319
|
+
placeholder="blur"
|
|
320
|
+
blurDataURL="data:image/jpeg;base64,..."
|
|
321
|
+
/>
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Anti-patterns
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
// BAD: Missing metadata
|
|
328
|
+
export default function Page() {
|
|
329
|
+
return <div>Content</div>;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// GOOD: Always include metadata
|
|
333
|
+
export const metadata: Metadata = {
|
|
334
|
+
title: 'Page Title',
|
|
335
|
+
description: 'Page description',
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// BAD: Duplicate content without canonical
|
|
339
|
+
// page-1 and page-2 have same content
|
|
340
|
+
|
|
341
|
+
// GOOD: Set canonical
|
|
342
|
+
export const metadata: Metadata = {
|
|
343
|
+
alternates: { canonical: '/page-1' },
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// BAD: Blocking indexing of important pages
|
|
347
|
+
export const metadata: Metadata = {
|
|
348
|
+
robots: { index: false }, // Accidentally blocking!
|
|
349
|
+
};
|
|
350
|
+
```
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "apps/**/app/**/*.tsx"
|
|
4
|
+
- "apps/**/app/**/*.ts"
|
|
5
|
+
- "app/**/*.tsx"
|
|
6
|
+
- "app/**/*.ts"
|
|
7
|
+
- "**/actions.ts"
|
|
8
|
+
- "**/actions/*.ts"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Next.js Server Actions
|
|
12
|
+
|
|
13
|
+
## Basic Server Action
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// app/actions.ts
|
|
17
|
+
'use server';
|
|
18
|
+
|
|
19
|
+
import { revalidatePath } from 'next/cache';
|
|
20
|
+
|
|
21
|
+
export async function createPost(formData: FormData) {
|
|
22
|
+
const title = formData.get('title') as string;
|
|
23
|
+
const content = formData.get('content') as string;
|
|
24
|
+
|
|
25
|
+
await db.post.create({ data: { title, content } });
|
|
26
|
+
|
|
27
|
+
revalidatePath('/posts');
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## With Validation (Zod)
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// app/actions.ts
|
|
35
|
+
'use server';
|
|
36
|
+
|
|
37
|
+
import { z } from 'zod';
|
|
38
|
+
import { revalidatePath } from 'next/cache';
|
|
39
|
+
|
|
40
|
+
const CreatePostSchema = z.object({
|
|
41
|
+
title: z.string().min(1).max(100),
|
|
42
|
+
content: z.string().min(1),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export async function createPost(formData: FormData) {
|
|
46
|
+
const parsed = CreatePostSchema.safeParse({
|
|
47
|
+
title: formData.get('title'),
|
|
48
|
+
content: formData.get('content'),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!parsed.success) {
|
|
52
|
+
return { error: parsed.error.flatten().fieldErrors };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await db.post.create({ data: parsed.data });
|
|
56
|
+
revalidatePath('/posts');
|
|
57
|
+
|
|
58
|
+
return { success: true };
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Return Type Pattern
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// Type-safe action results
|
|
66
|
+
type ActionResult<T> =
|
|
67
|
+
| { success: true; data: T }
|
|
68
|
+
| { success: false; error: string; fieldErrors?: Record<string, string[]> };
|
|
69
|
+
|
|
70
|
+
export async function createUser(formData: FormData): Promise<ActionResult<User>> {
|
|
71
|
+
const parsed = UserSchema.safeParse(Object.fromEntries(formData));
|
|
72
|
+
|
|
73
|
+
if (!parsed.success) {
|
|
74
|
+
return {
|
|
75
|
+
success: false,
|
|
76
|
+
error: 'Validation failed',
|
|
77
|
+
fieldErrors: parsed.error.flatten().fieldErrors,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const user = await db.user.create({ data: parsed.data });
|
|
83
|
+
revalidatePath('/users');
|
|
84
|
+
return { success: true, data: user };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { success: false, error: 'Failed to create user' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## useActionState (React 19)
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
'use client';
|
|
95
|
+
|
|
96
|
+
import { useActionState } from 'react';
|
|
97
|
+
import { createPost } from './actions';
|
|
98
|
+
|
|
99
|
+
export function PostForm() {
|
|
100
|
+
const [state, formAction, isPending] = useActionState(createPost, null);
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<form action={formAction}>
|
|
104
|
+
<input name="title" disabled={isPending} />
|
|
105
|
+
{state?.fieldErrors?.title && (
|
|
106
|
+
<span className="error">{state.fieldErrors.title}</span>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
<textarea name="content" disabled={isPending} />
|
|
110
|
+
{state?.fieldErrors?.content && (
|
|
111
|
+
<span className="error">{state.fieldErrors.content}</span>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
<button type="submit" disabled={isPending}>
|
|
115
|
+
{isPending ? 'Creating...' : 'Create Post'}
|
|
116
|
+
</button>
|
|
117
|
+
|
|
118
|
+
{state?.error && <div className="error">{state.error}</div>}
|
|
119
|
+
</form>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## useOptimistic
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
'use client';
|
|
128
|
+
|
|
129
|
+
import { useOptimistic } from 'react';
|
|
130
|
+
import { toggleLike } from './actions';
|
|
131
|
+
|
|
132
|
+
interface Post {
|
|
133
|
+
id: string;
|
|
134
|
+
likes: number;
|
|
135
|
+
isLiked: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function LikeButton({ post }: { post: Post }) {
|
|
139
|
+
const [optimisticPost, addOptimistic] = useOptimistic(
|
|
140
|
+
post,
|
|
141
|
+
(state, newLiked: boolean) => ({
|
|
142
|
+
...state,
|
|
143
|
+
isLiked: newLiked,
|
|
144
|
+
likes: newLiked ? state.likes + 1 : state.likes - 1,
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
async function handleClick() {
|
|
149
|
+
addOptimistic(!optimisticPost.isLiked);
|
|
150
|
+
await toggleLike(post.id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<button onClick={handleClick}>
|
|
155
|
+
{optimisticPost.isLiked ? '❤️' : '🤍'} {optimisticPost.likes}
|
|
156
|
+
</button>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Revalidation Strategies
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
'use server';
|
|
165
|
+
|
|
166
|
+
import { revalidatePath, revalidateTag } from 'next/cache';
|
|
167
|
+
|
|
168
|
+
export async function createPost(data: PostData) {
|
|
169
|
+
await db.post.create({ data });
|
|
170
|
+
|
|
171
|
+
// Revalidate specific path
|
|
172
|
+
revalidatePath('/posts');
|
|
173
|
+
|
|
174
|
+
// Revalidate dynamic path
|
|
175
|
+
revalidatePath(`/posts/${data.slug}`);
|
|
176
|
+
|
|
177
|
+
// Revalidate layout (all child pages)
|
|
178
|
+
revalidatePath('/posts', 'layout');
|
|
179
|
+
|
|
180
|
+
// Revalidate by cache tag
|
|
181
|
+
revalidateTag('posts');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// In data fetching, use tags
|
|
185
|
+
async function getPosts() {
|
|
186
|
+
return fetch('/api/posts', {
|
|
187
|
+
next: { tags: ['posts'] },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Redirect After Action
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
'use server';
|
|
196
|
+
|
|
197
|
+
import { redirect } from 'next/navigation';
|
|
198
|
+
|
|
199
|
+
export async function createPost(formData: FormData) {
|
|
200
|
+
const post = await db.post.create({
|
|
201
|
+
data: {
|
|
202
|
+
title: formData.get('title') as string,
|
|
203
|
+
content: formData.get('content') as string,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
redirect(`/posts/${post.slug}`);
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## With Authentication
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
'use server';
|
|
215
|
+
|
|
216
|
+
import { auth } from '@/lib/auth';
|
|
217
|
+
import { redirect } from 'next/navigation';
|
|
218
|
+
|
|
219
|
+
export async function createPost(formData: FormData) {
|
|
220
|
+
const session = await auth();
|
|
221
|
+
|
|
222
|
+
if (!session?.user) {
|
|
223
|
+
redirect('/login');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await db.post.create({
|
|
227
|
+
data: {
|
|
228
|
+
title: formData.get('title') as string,
|
|
229
|
+
content: formData.get('content') as string,
|
|
230
|
+
authorId: session.user.id,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
revalidatePath('/posts');
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## File Upload
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
'use server';
|
|
242
|
+
|
|
243
|
+
export async function uploadFile(formData: FormData) {
|
|
244
|
+
const file = formData.get('file') as File;
|
|
245
|
+
|
|
246
|
+
if (!file || file.size === 0) {
|
|
247
|
+
return { error: 'No file provided' };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Validate file type
|
|
251
|
+
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
|
252
|
+
if (!allowedTypes.includes(file.type)) {
|
|
253
|
+
return { error: 'Invalid file type' };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Validate file size (5MB)
|
|
257
|
+
if (file.size > 5 * 1024 * 1024) {
|
|
258
|
+
return { error: 'File too large' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const bytes = await file.arrayBuffer();
|
|
262
|
+
const buffer = Buffer.from(bytes);
|
|
263
|
+
|
|
264
|
+
// Save to storage (S3, local, etc.)
|
|
265
|
+
const url = await uploadToStorage(buffer, file.name);
|
|
266
|
+
|
|
267
|
+
return { success: true, url };
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Progressive Enhancement
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
// Works without JavaScript (form submits normally)
|
|
275
|
+
// Enhanced with JavaScript (no page reload)
|
|
276
|
+
|
|
277
|
+
export function ContactForm() {
|
|
278
|
+
return (
|
|
279
|
+
<form action={sendMessage}>
|
|
280
|
+
<input name="email" type="email" required />
|
|
281
|
+
<textarea name="message" required />
|
|
282
|
+
<button type="submit">Send</button>
|
|
283
|
+
</form>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Error Handling
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
'use server';
|
|
292
|
+
|
|
293
|
+
export async function riskyAction(formData: FormData) {
|
|
294
|
+
try {
|
|
295
|
+
await someRiskyOperation();
|
|
296
|
+
return { success: true };
|
|
297
|
+
} catch (error) {
|
|
298
|
+
// Log server-side
|
|
299
|
+
console.error('Action failed:', error);
|
|
300
|
+
|
|
301
|
+
// Return safe error to client
|
|
302
|
+
if (error instanceof KnownError) {
|
|
303
|
+
return { success: false, error: error.message };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { success: false, error: 'An unexpected error occurred' };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Binding Arguments
|
|
312
|
+
|
|
313
|
+
```tsx
|
|
314
|
+
// Pass additional data to action
|
|
315
|
+
import { updatePost } from './actions';
|
|
316
|
+
|
|
317
|
+
export function EditButton({ postId }: { postId: string }) {
|
|
318
|
+
const updateWithId = updatePost.bind(null, postId);
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<form action={updateWithId}>
|
|
322
|
+
<input name="title" />
|
|
323
|
+
<button type="submit">Update</button>
|
|
324
|
+
</form>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// actions.ts
|
|
329
|
+
'use server';
|
|
330
|
+
|
|
331
|
+
export async function updatePost(postId: string, formData: FormData) {
|
|
332
|
+
const title = formData.get('title') as string;
|
|
333
|
+
await db.post.update({ where: { id: postId }, data: { title } });
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Non-Form Usage
|
|
338
|
+
|
|
339
|
+
```tsx
|
|
340
|
+
'use client';
|
|
341
|
+
|
|
342
|
+
import { deletePost } from './actions';
|
|
343
|
+
|
|
344
|
+
export function DeleteButton({ postId }: { postId: string }) {
|
|
345
|
+
async function handleDelete() {
|
|
346
|
+
if (confirm('Are you sure?')) {
|
|
347
|
+
await deletePost(postId);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return <button onClick={handleDelete}>Delete</button>;
|
|
352
|
+
}
|
|
353
|
+
```
|