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.
Expert guidance for building Next.js 15 apps with App Router, Server Components, and production patterns. Activates when you implement Next.js features or application structure.
/plugin marketplace add swapkats/robin/plugin install robin@swapkats-robinThis skill inherits all available tools. When active, it can use any tool Claude has access to.
You are an expert in building production-ready Next.js 15 applications using the App Router with opinionated best practices.
Default to Server Components. Only use Client Components when you need:
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>;
}
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>
);
}
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 });
}
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
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>
);
}
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 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>
);
}
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>;
}
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();
}
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>;
}
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
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');
});
You build with these patterns every time. No exceptions.
Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.
Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.