
building-nextjs-apps
by swapkats
A hyper-opinionated agent for building production-ready apps
SKILL.md
name: building-nextjs-apps description: Specialized skill for building Next.js 15 App Router applications with React Server Components, Server Actions, and production-ready patterns. Use when implementing Next.js features, components, or application structure.
Building Next.js Apps
You are an expert in building production-ready Next.js 15 applications using the App Router with opinionated best practices.
Enforced Patterns
App Router Only
- NEVER use Pages Router
- Use App Router features: layouts, loading, error, not-found
- Leverage nested layouts for shared UI
- Use route groups for organization (no URL impact)
Server Components First
Default to Server Components. Only use Client Components when you need:
- Interactivity (event handlers: onClick, onChange, etc.)
- Browser-only APIs (localStorage, window, document)
- React hooks (useState, useEffect, useReducer, etc.)
- Third-party libraries that require client-side rendering
Data Fetching
Server Components (Preferred):
// app/posts/page.tsx
import { getPosts } from '@/lib/data';
export default async function PostsPage() {
const posts = await getPosts(); // Direct async call
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
);
}
Client Components (When needed):
// components/posts-list.tsx
'use client';
import { useEffect, useState } from 'react';
export function PostsList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(setPosts);
}, []);
return <div>{/* render posts */}</div>;
}
Mutations with Server Actions
Form Actions (Preferred):
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title required'),
content: z.string().min(1, 'Content required'),
});
export async function createPost(formData: FormData) {
const validated = CreatePostSchema.parse({
title: formData.get('title'),
content: formData.get('content'),
});
// Write to database
const postId = await db.createPost(validated);
revalidatePath('/posts');
redirect(`/posts/${postId}`);
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
);
}
Programmatic Actions:
// components/delete-button.tsx
'use client';
import { deletePost } from '@/app/actions';
export function DeleteButton({ postId }: { postId: string }) {
return (
<button onClick={() => deletePost(postId)}>
Delete
</button>
);
}
Route Handlers
Use for external API integrations, webhooks, or when Server Actions don't fit:
// app/api/webhook/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
export async function POST(request: Request) {
const headersList = headers();
const signature = headersList.get('x-webhook-signature');
// Verify signature
if (!verifySignature(signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const body = await request.json();
// Process webhook
await processWebhook(body);
return NextResponse.json({ success: true });
}
File Structure
app/
├── (auth)/ # Route group (no /auth in URL)
│ ├── login/
│ │ └── page.tsx
│ ├── register/
│ │ └── page.tsx
│ └── layout.tsx # Shared auth layout
├── (dashboard)/ # Another route group
│ ├── posts/
│ │ ├── [id]/
│ │ │ ├── page.tsx # /posts/[id]
│ │ │ └── edit/
│ │ │ └── page.tsx # /posts/[id]/edit
│ │ ├── new/
│ │ │ └── page.tsx # /posts/new
│ │ ├── page.tsx # /posts
│ │ ├── loading.tsx # Loading UI
│ │ └── error.tsx # Error boundary
│ ├── settings/
│ │ └── page.tsx
│ └── layout.tsx # Dashboard layout with nav
├── api/
│ ├── webhook/
│ │ └── route.ts
│ └── health/
│ └── route.ts
├── actions.ts # Server Actions
├── layout.tsx # Root layout
├── page.tsx # Home page
├── loading.tsx # Global loading
├── error.tsx # Global error
├── not-found.tsx # 404 page
└── global.css # Tailwind imports
components/
├── ui/ # Reusable UI components
│ ├── button.tsx
│ ├── card.tsx
│ └── input.tsx
└── features/ # Feature-specific components
├── post-card.tsx
└── post-form.tsx
lib/
├── db/ # Database access
│ ├── dynamodb.ts
│ └── queries.ts
├── auth/ # Auth utilities
│ └── config.ts
└── utils.ts # Shared utilities
Layouts
Root Layout (Required):
// app/layout.tsx
import './global.css';
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
export const metadata = {
title: 'My App',
description: 'App description',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
{children}
</body>
</html>
);
}
Nested Layouts:
// app/(dashboard)/layout.tsx
import { Navigation } from '@/components/navigation';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen">
<Navigation />
<main className="flex-1 overflow-y-auto p-8">
{children}
</main>
</div>
);
}
Loading States
Streaming with Suspense:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { PostsList } from '@/components/posts-list';
import { StatsSkeleton } from '@/components/skeletons';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<PostsList />
</Suspense>
</div>
);
}
Loading.tsx:
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900" />
</div>
);
}
Error Handling
Error Boundaries:
// app/dashboard/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Try again
</button>
</div>
);
}
Not Found:
// app/posts/[id]/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h2>Post Not Found</h2>
<p>Could not find the requested post.</p>
<Link href="/posts">View all posts</Link>
</div>
);
}
Metadata
Static Metadata:
// app/posts/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Posts',
description: 'Browse all posts',
};
export default function PostsPage() {
// ...
}
Dynamic Metadata:
// app/posts/[id]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
export async function generateMetadata({
params,
}: {
params: { id: string };
}): Promise<Metadata> {
const post = await getPost(params.id);
if (!post) {
return {
title: 'Post Not Found',
};
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
export default async function PostPage({
params,
}: {
params: { id: string };
}) {
const post = await getPost(params.id);
if (!post) {
notFound();
}
return <article>{/* render post */}</article>;
}
Caching and Revalidation
Revalidate Paths:
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(data: FormData) {
await db.createPost(/* ... */);
revalidatePath('/posts'); // Revalidate specific path
revalidatePath('/posts/[id]', 'page'); // Revalidate dynamic route
revalidatePath('/', 'layout'); // Revalidate layout (all nested pages)
}
Revalidate Tags:
// Fetch with tag
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
return res.json();
}
// Revalidate by tag
import { revalidateTag } from 'next/cache';
export async function createPost(data: FormData) {
await db.createPost(/* ... */);
revalidateTag('posts'); // Revalidates all fetches with 'posts' tag
}
Time-based Revalidation:
// Revalidate every hour
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 },
});
return res.json();
}
Authentication with NextAuth.js
Configuration:
// lib/auth/config.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import { DynamoDBAdapter } from '@auth/dynamodb-adapter';
import { DynamoDB } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
const client = DynamoDBDocument.from(new DynamoDB({}), {
marshallOptions: {
convertEmptyValues: true,
removeUndefinedValues: true,
convertClassInstanceToMap: true,
},
});
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DynamoDBAdapter(client),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
session: {
strategy: 'jwt',
},
});
API Route:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth/config';
export const { GET, POST } = handlers;
Middleware (Protect routes):
// middleware.ts
import { auth } from '@/lib/auth/config';
export default auth((req) => {
if (!req.auth && req.nextUrl.pathname.startsWith('/dashboard')) {
return Response.redirect(new URL('/login', req.url));
}
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Get Session (Server Component):
import { auth } from '@/lib/auth/config';
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
return <div>Welcome, {session.user.name}</div>;
}
Get Session (Client Component):
'use client';
import { useSession } from 'next-auth/react';
export function UserProfile() {
const { data: session, status } = useSession();
if (status === 'loading') {
return <div>Loading...</div>;
}
if (status === 'unauthenticated') {
return <div>Not signed in</div>;
}
return <div>Signed in as {session?.user?.name}</div>;
}
Environment Variables
Validation:
// lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
DYNAMODB_TABLE_NAME: z.string().min(1),
AWS_REGION: z.string().min(1),
GOOGLE_CLIENT_ID: z.string().min(1),
GOOGLE_CLIENT_SECRET: z.string().min(1),
NEXTAUTH_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
});
export const env = envSchema.parse(process.env);
.env.example:
# Database
DYNAMODB_TABLE_NAME=my-app-table
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
# Auth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-nextauth-secret-min-32-chars
Testing
Unit Tests (Vitest):
// lib/utils.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate } from './utils';
describe('formatDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-01-01');
expect(formatDate(date)).toBe('January 1, 2024');
});
});
E2E Tests (Playwright):
// tests/e2e/posts.spec.ts
import { test, expect } from '@playwright/test';
test('create new post', async ({ page }) => {
await page.goto('/posts/new');
await page.fill('input[name="title"]', 'Test Post');
await page.fill('textarea[name="content"]', 'Test content');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/posts\/\w+/);
await expect(page.locator('h1')).toContainText('Test Post');
});
Best Practices Summary
- Server Components by default - Use 'use client' sparingly
- Server Actions for mutations - Forms and programmatic actions
- Async Server Components - Fetch data directly in components
- Nested layouts - Share UI across routes
- Loading and error states - Use loading.tsx, error.tsx, Suspense
- Metadata API - Static and dynamic SEO
- Route groups - Organize without affecting URLs
- Streaming - Progressive rendering with Suspense
- Revalidation - Keep data fresh with revalidatePath/revalidateTag
- Type-safe environment variables - Validate with Zod
You build with these patterns every time. No exceptions.
Score
Total Score
Based on repository quality metrics
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
3ヶ月以内に更新がある
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
Reviews
Reviews coming soon