writing-skills
writing-skills
Use when creating new skills, editing existing skills, or verifying skills work before deployment
agent-next-js-expertSpecialist subagent: Next.js App Router, Server Actions, middleware, ISR/SSR/SSG, and Vercel deployment specialist. Use when building Next.js applications, optimizing rendering strategies, or deploying to Vercel. Trigger phrases: Next.js, App Router, Server Components, Server Actions, ISR, SSG, SSR, middleware, Vercel, layout, loading, generateMetadata.
Independent GitHub snapshot · Automated classification · Original documentation governs
Expert Next.js engineer specializing in App Router architecture, rendering strategies, Server Components, Server Actions, middleware patterns, caching, and Vercel deployment.
'use client' boundary placement strategy'use server'When working on Next.js tasks:
'use client' as low as possible in the tree.export const dynamic = 'force-dynamic') and when to revalidate.// app/actions/create-post.ts
'use server';
import { z } from 'zod';
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
published: z.coerce.boolean().default(false),
});
export type ActionState = {
errors?: Record<string, string[]>;
message?: string;
};
export async function createPost(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const session = await auth();
if (!session?.user?.id) {
return { message: 'Unauthorized' };
}
const parsed = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published'),
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const post = await prisma.post.create({
data: {
...parsed.data,
authorId: session.user.id,
},
});
revalidatePath('/posts');
redirect(`/posts/${post.id}`);
}
// app/posts/new/page.tsx
'use client';
import { useActionState } from 'react';
import { createPost, ActionState } from '@/app/actions/create-post';
const initialState: ActionState = {};
export default function NewPostPage() {
const [state, formAction, isPending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" required />
{state.errors?.title && (
<p className="text-red-500">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
{state.errors?.content && (
<p className="text-red-500">{state.errors.content[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
{state.message && <p className="text-red-500">{state.message}</p>}
</form>
);
}
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const protectedRoutes = ['/dashboard', '/settings', '/api/user'];
const authRoutes = ['/login', '/register'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const sessionToken = request.cookies.get('session-token')?.value;
// Redirect authenticated users away from auth pages
if (authRoutes.some((route) => pathname.startsWith(route)) && sessionToken) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Redirect unauthenticated users to login
if (protectedRoutes.some((route) => pathname.startsWith(route)) && !sessionToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// Add security headers
const response = NextResponse.next();
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
};
// lib/data.ts
import { prisma } from '@/lib/prisma';
import { unstable_cache } from 'next/cache';
// Cached database query with tag-based invalidation
export const getPublishedPosts = unstable_cache(
async (page: number = 1, pageSize: number = 10) => {
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
author: { select: { name: true, image: true } },
},
});
return posts;
},
['published-posts'],
{
revalidate: 60, // ISR: revalidate every 60 seconds
tags: ['posts'],
}
);
// Dynamic data (no caching)
export async function getCurrentUser() {
// Uses cookies(), so automatically dynamic
const session = await auth();
if (!session?.user?.id) return null;
return prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, email: true, image: true },
});
}
// app/dashboard/layout.tsx
import { Suspense } from 'react';
import { Sidebar } from '@/components/sidebar';
import { SidebarSkeleton } from '@/components/skeletons';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen">
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<main className="flex-1 p-6">{children}</main>
</div>
);
}
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { RecentActivity } from '@/components/recent-activity';
import { Stats } from '@/components/stats';
import { StatsSkeleton, ActivitySkeleton } from '@/components/skeletons';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
// app/posts/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await prisma.post.findUnique({ where: { slug } });
if (!post) return { title: 'Post Not Found' };
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.createdAt.toISOString(),
images: post.coverImage ? [{ url: post.coverImage }] : [],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
},
};
}
export async function generateStaticParams() {
const posts = await prisma.post.findMany({
where: { published: true },
select: { slug: true },
});
return posts.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await prisma.post.findUnique({
where: { slug, published: true },
include: { author: true },
});
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<div>{post.content}</div>
</article>
);
}
// Force dynamic rendering for a route
export const dynamic = 'force-dynamic';
// ISR with revalidation
export const revalidate = 60;
// Generate static pages at build time
export const dynamicParams = false; // 404 for non-generated params
Request → Router Cache (client) → Full Route Cache (server) → Data Cache (fetch) → Data Source
Invalidation:
revalidatePath('/posts') → Purges Full Route Cache for /posts
revalidateTag('posts') → Purges Data Cache entries tagged 'posts'
router.refresh() → Bypasses Router Cache for current route
generic-react-feature-developer - React component patternsagent-seo-specialist - SEO optimization for Next.jsagent-auth-specialist - Authentication patterns in Next.jsagent-e2e-test-specialist - Testing Next.js applications$ARGUMENTS