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.
68
80%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No known issues
Fix and improve this skill with Tessl
tessl review fix ./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.