Claude Code Plugins

Community-maintained marketplace

Feedback

building-nextjs-apps

@swapkats/robin
0
0

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.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

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

  1. Server Components by default - Use 'use client' sparingly
  2. Server Actions for mutations - Forms and programmatic actions
  3. Async Server Components - Fetch data directly in components
  4. Nested layouts - Share UI across routes
  5. Loading and error states - Use loading.tsx, error.tsx, Suspense
  6. Metadata API - Static and dynamic SEO
  7. Route groups - Organize without affecting URLs
  8. Streaming - Progressive rendering with Suspense
  9. Revalidation - Keep data fresh with revalidatePath/revalidateTag
  10. Type-safe environment variables - Validate with Zod

You build with these patterns every time. No exceptions.