Scaffold a Next.js 15 App Router project with TypeScript, server/client component conventions, route groups, layouts, loading/error boundaries, Route Handlers, middleware, and SSR/SSG/ISR strategies.
85
80%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Optimize this skill with Tessl
npx tessl skill review --optimize ./frontend/nextjs-project-starter/SKILL.mdScaffold a Next.js 15 App Router project with TypeScript, server/client component conventions, route groups, layouts, loading/error boundaries, Route Handlers, middleware, and SSR/SSG/ISR strategies.
npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd my-appThe CLI will prompt for options. Select:
src/ directory: Yes@/*src/
├── app/
│ ├── (marketing)/ # Route group — no URL segment
│ │ ├── page.tsx # Landing page (/)
│ │ ├── about/
│ │ │ └── page.tsx # /about
│ │ └── layout.tsx # Layout for marketing pages
│ ├── (app)/ # Route group for authenticated app
│ │ ├── dashboard/
│ │ │ ├── page.tsx # /dashboard
│ │ │ ├── loading.tsx # Loading UI for dashboard
│ │ │ └── error.tsx # Error boundary for dashboard
│ │ ├── settings/
│ │ │ └── page.tsx # /settings
│ │ └── layout.tsx # Shared layout with sidebar/nav
│ ├── api/
│ │ ├── health/
│ │ │ └── route.ts # GET /api/health
│ │ └── users/
│ │ └── route.ts # GET/POST /api/users
│ ├── layout.tsx # Root layout (html, body, providers)
│ ├── not-found.tsx # Custom 404 page
│ └── global-error.tsx # Root error boundary
├── components/
│ ├── ui/ # Shared UI primitives (Button, Card, etc.)
│ └── layout/ # Layout components (Header, Sidebar, Footer)
├── lib/
│ ├── db.ts # Database client
│ ├── auth.ts # Auth helpers
│ └── utils.ts # Shared utility functions
├── services/ # Server-side data fetching functions
│ └── users.ts
├── types/ # Shared TypeScript types
│ └── index.ts
├── middleware.ts # Next.js middleware (root of src/)
└── styles/
└── globals.css # Tailwind + global styles
next.config.ts # Next.js configuration
.env.example # Required env vars templateapp/ is a Server Component unless it has "use client" at the top. Keep "use client" at the leaf level.(groupName) folders for organizational grouping without affecting the URL structure.src/components/, src/lib/, src/types/.fetch() with Next.js caching options or call DB/service functions directly.loading.tsx: place next to page.tsx for automatic Suspense-based loading states.error.tsx: place next to page.tsx for automatic error boundaries (must be "use client").route.ts files in app/api/ for API endpoints. Export named functions (GET, POST, PUT, DELETE).metadata or generateMetadata() from page/layout files for SEO.next.config.ts)import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Enable React strict mode
reactStrictMode: true,
// Image optimization domains
images: {
remotePatterns: [
{
protocol: "https",
hostname: "example.com",
},
],
},
// Redirect and rewrite examples
async redirects() {
return [
{
source: "/old-page",
destination: "/new-page",
permanent: true,
},
];
},
// Environment variables exposed to the browser
env: {
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
};
export default nextConfig;// src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "@/styles/globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: {
default: "My App",
template: "%s | My App",
},
description: "A Next.js 15 application",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}// src/app/(app)/dashboard/page.tsx
import type { Metadata } from "next";
import { getUsers } from "@/services/users";
import { UserList } from "./UserList";
export const metadata: Metadata = {
title: "Dashboard",
};
export default async function DashboardPage() {
const users = await getUsers();
return (
<main className="p-6">
<h1 className="mb-4 text-2xl font-bold">Dashboard</h1>
<UserList users={users} />
</main>
);
}// src/app/(app)/dashboard/UserList.tsx
"use client";
import { useState } from "react";
interface User {
id: string;
name: string;
email: string;
}
export function UserList({ users }: { users: User[] }) {
const [search, setSearch] = useState("");
const filtered = users.filter((u) =>
u.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
className="mb-4 rounded border px-3 py-2"
/>
<ul className="space-y-2">
{filtered.map((user) => (
<li key={user.id} className="rounded border p-3">
<p className="font-medium">{user.name}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</li>
))}
</ul>
</div>
);
}// src/app/(app)/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="p-6">
<div className="mb-4 h-8 w-48 animate-pulse rounded bg-gray-200" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded bg-gray-200" />
))}
</div>
</div>
);
}// src/app/(app)/dashboard/error.tsx
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center gap-4 p-6">
<h2 className="text-xl font-semibold text-red-600">Something went wrong</h2>
<p className="text-gray-600">{error.message}</p>
<button
onClick={reset}
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Try again
</button>
</div>
);
}// src/app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getUsers, createUser } from "@/services/users";
export async function GET() {
const users = await getUsers();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.name || !body.email) {
return NextResponse.json(
{ error: "Name and email are required" },
{ status: 400 }
);
}
const user = await createUser(body);
return NextResponse.json(user, { status: 201 });
}// src/middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("session-token")?.value;
// Protect /dashboard and /settings routes
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
if (!token && request.nextUrl.pathname.startsWith("/settings")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};// src/app/(app)/settings/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function updateProfile(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
// Validate and save to DB
// await db.user.update({ ... });
revalidatePath("/settings");
}// SSR — fresh data on every request (default in App Router with dynamic data)
export default async function SSRPage() {
const data = await fetch("https://api.example.com/data", {
cache: "no-store",
});
return <div>{/* render data */}</div>;
}
// SSG — generated at build time
export default async function SSGPage() {
const data = await fetch("https://api.example.com/data", {
cache: "force-cache",
});
return <div>{/* render data */}</div>;
}
// ISR — revalidate every 60 seconds
export default async function ISRPage() {
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 60 },
});
return <div>{/* render data */}</div>;
}
// Route-level revalidation config
export const revalidate = 60; // ISR: revalidate this page every 60s
export const dynamic = "force-dynamic"; // SSR: always render on request
export const dynamic = "force-static"; // SSG: always static// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetchPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await fetchPost(slug);
return <article><h1>{post.title}</h1><div>{post.content}</div></article>;
}// src/app/(app)/users/[id]/page.tsx
import type { Metadata } from "next";
interface Props {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const user = await getUser(id);
return {
title: user.name,
description: `Profile page for ${user.name}`,
};
}
export default async function UserPage({ params }: Props) {
const { id } = await params;
const user = await getUser(id);
return <div>{user.name}</div>;
}.env.example to .env.local and fill in valuesnpm installnpm run devnpx tsc --noEmit to confirm TypeScript is clean# Development
npm run dev # Start dev server with Turbopack (http://localhost:3000)
# Build & Production
npm run build # Create production build
npm run start # Start production server
# Lint
npm run lint # Run Next.js ESLint rules
# Type check
npx tsc --noEmitsrc/lib/db.ts and call it directly in Server Components or Route Handlers.output: "standalone" in next.config.ts for Docker/self-hosted.revalidatePath/revalidateTag.181fcbc
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.