@josiahsiegel/nextjs-app-router

|

View in AI SkillSafe app
0 downloads
0 stars
0 demos
SKILL.md
namenextjs-app-router
description|

Quick Reference

Convention File Purpose
Page page.tsx Route UI (required for route)
Layout layout.tsx Shared UI (persists across navigations)
Loading loading.tsx Loading UI with Suspense
Error error.tsx Error boundary (must be 'use client')
Not Found not-found.tsx 404 UI
Template template.tsx Re-renders on navigation
Pattern Syntax Example
Dynamic Route [slug] /blog/[slug]/page.tsx
Catch-all [...slug] /docs/[...slug]/page.tsx
Optional Catch-all [[...slug]] /shop/[[...slug]]/page.tsx
Route Group (name) /(marketing)/about/page.tsx
Parallel Route @slot /@analytics/page.tsx

When to Use This Skill

Use for App Router fundamentals:

  • Setting up new Next.js 15 projects with App Router
  • Understanding file-based routing conventions
  • Creating layouts that persist across navigation
  • Implementing loading states and error boundaries
  • Organizing routes with groups

Related skills:

  • For data fetching: see nextjs-data-fetching
  • For advanced routing: see nextjs-routing-advanced
  • For caching strategies: see nextjs-caching

Next.js App Router Fundamentals

Project Structure

App Directory

app/
├── layout.tsx              # Root layout (required)
├── page.tsx                # Home page (/)
├── loading.tsx             # Loading UI
├── error.tsx               # Error boundary
├── not-found.tsx           # 404 page
├── global-error.tsx        # Global error boundary
├── template.tsx            # Re-renders on navigation
│
├── (marketing)/            # Route group (no URL impact)
│   ├── about/
│   │   └── page.tsx        # /about
│   └── contact/
│       └── page.tsx        # /contact
│
├── dashboard/
│   ├── layout.tsx          # Dashboard layout
│   ├── page.tsx            # /dashboard
│   ├── loading.tsx         # Dashboard loading
│   ├── @analytics/         # Parallel route
│   │   └── page.tsx
│   ├── @team/              # Parallel route
│   │   └── page.tsx
│   └── settings/
│       └── page.tsx        # /dashboard/settings
│
├── blog/
│   ├── page.tsx            # /blog
│   └── [slug]/             # Dynamic route
│       ├── page.tsx        # /blog/:slug
│       └── opengraph-image.tsx
│
├── products/
│   └── [...slug]/          # Catch-all route
│       └── page.tsx        # /products/*
│
├── shop/
│   └── [[...slug]]/        # Optional catch-all
│       └── page.tsx        # /shop or /shop/*
│
└── api/
    └── users/
        └── route.ts        # API route handler

Layouts

Root Layout (Required)

// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: {
    template: '%s | My App',
    default: 'My App',
  },
  description: 'My application description',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <header>
          <nav>{/* Navigation */}</nav>
        </header>
        <main>{children}</main>
        <footer>{/* Footer */}</footer>
      </body>
    </html>
  );
}

Nested Layout

// app/dashboard/layout.tsx
import { Sidebar } from '@/components/sidebar';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard">
      <Sidebar />
      <div className="dashboard-content">{children}</div>
    </div>
  );
}

Layout with Parallel Routes

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <div className="dashboard">
      <div className="main">{children}</div>
      <div className="sidebar">
        {analytics}
        {team}
      </div>
    </div>
  );
}

Pages

Basic Page

// app/page.tsx
export default function HomePage() {
  return (
    <div>
      <h1>Welcome to My App</h1>
      <p>This is the home page.</p>
    </div>
  );
}

Async Server Component Page

// app/posts/page.tsx
import { db } from '@/lib/db';

async function getPosts() {
  return db.posts.findMany({
    orderBy: { createdAt: 'desc' },
  });
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <div>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={`/posts/${post.slug}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Dynamic Route Page

// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';

interface PageProps {
  params: Promise<{ slug: string }>;
}

export async function generateMetadata({ params }: PageProps) {
  const { slug } = await params;
  const post = await db.posts.findUnique({ where: { slug } });

  if (!post) {
    return { title: 'Post Not Found' };
  }

  return {
    title: post.title,
    description: post.excerpt,
  };
}

export async function generateStaticParams() {
  const posts = await db.posts.findMany({ select: { slug: true } });
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function PostPage({ params }: PageProps) {
  const { slug } = await params;
  const post = await db.posts.findUnique({ where: { slug } });

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Loading UI

Loading Component

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="loading-container">
      <div className="spinner" />
      <p>Loading dashboard...</p>
    </div>
  );
}

Skeleton Loading

// app/posts/loading.tsx
export default function PostsLoading() {
  return (
    <div className="posts-skeleton">
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="post-skeleton">
          <div className="skeleton-title" />
          <div className="skeleton-excerpt" />
        </div>
      ))}
    </div>
  );
}

Streaming with Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { Analytics } from './analytics';
import { RecentSales } from './recent-sales';

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics />
      </Suspense>

      <Suspense fallback={<SalesSkeleton />}>
        <RecentSales />
      </Suspense>
    </div>
  );
}

Error Handling

Error Boundary

// app/dashboard/error.tsx
'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div className="error-container">
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

Global Error Boundary

// app/global-error.tsx
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <button onClick={() => reset()}>Try again</button>
      </body>
    </html>
  );
}

Not Found Page

// app/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="not-found">
      <h2>Page Not Found</h2>
      <p>Could not find the requested resource.</p>
      <Link href="/">Return Home</Link>
    </div>
  );
}

Triggering Not Found

// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function PostPage({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return <article>{/* ... */}</article>;
}

Metadata

Static Metadata

// app/about/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'About Us',
  description: 'Learn more about our company',
  openGraph: {
    title: 'About Us',
    description: 'Learn more about our company',
    images: ['/og-about.jpg'],
  },
};

export default function AboutPage() {
  return <div>About content</div>;
}

Dynamic Metadata

// app/products/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';

interface PageProps {
  params: Promise<{ id: string }>;
}

export async function generateMetadata(
  { params }: PageProps,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { id } = await params;
  const product = await getProduct(id);

  const previousImages = (await parent).openGraph?.images || [];

  return {
    title: product.name,
    description: product.description,
    openGraph: {
      images: [product.image, ...previousImages],
    },
  };
}

Metadata Template

// app/layout.tsx
export const metadata: Metadata = {
  title: {
    template: '%s | My Store',
    default: 'My Store',
  },
  metadataBase: new URL('https://mystore.com'),
};

// app/products/page.tsx
export const metadata: Metadata = {
  title: 'Products', // Results in "Products | My Store"
};

Templates vs Layouts

// app/dashboard/template.tsx
// Template re-mounts on navigation (state resets)
export default function DashboardTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div>
      <nav>Dashboard Navigation</nav>
      {children}
    </div>
  );
}

Route Groups

// (marketing)/about/page.tsx → /about
// (marketing)/contact/page.tsx → /contact
// (shop)/products/page.tsx → /products
// (shop)/cart/page.tsx → /cart

// Different layouts for different sections
// app/(marketing)/layout.tsx - Marketing layout
// app/(shop)/layout.tsx - Shop layout

Client Components

// components/Counter.tsx
'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

Composing Server and Client Components

// app/page.tsx (Server Component)
import { Counter } from '@/components/Counter';
import { db } from '@/lib/db';

export default async function Page() {
  const initialData = await db.getData();

  return (
    <div>
      <h1>Server rendered title</h1>
      <Counter />
      <ClientDataDisplay data={initialData} />
    </div>
  );
}

Navigation

Link Component

import Link from 'next/link';

export function Navigation() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/about">About</Link>
      <Link href="/blog" prefetch={false}>Blog</Link>
      <Link href="/dashboard" replace>Dashboard</Link>
    </nav>
  );
}

useRouter Hook

'use client';

import { useRouter } from 'next/navigation';

export function NavigateButton() {
  const router = useRouter();

  return (
    <button onClick={() => router.push('/dashboard')}>
      Go to Dashboard
    </button>
  );
}

usePathname and useSearchParams

'use client';

import { usePathname, useSearchParams } from 'next/navigation';

export function CurrentPath() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const query = searchParams.get('q');

  return (
    <div>
      <p>Current path: {pathname}</p>
      {query && <p>Search query: {query}</p>}
    </div>
  );
}

Best Practices

Practice Description
Default to Server Components Only use 'use client' when needed
Colocate related files Keep page, loading, error together
Use route groups Organize without affecting URL
Implement loading states Use loading.tsx or Suspense
Handle errors gracefully Use error.tsx boundaries
Optimize metadata Use generateMetadata for dynamic pages

Embed badges

Add these to your README to show the skill's verification status.

SkillSafe verified badge
Verified badge
[![SkillSafe verified badge](https://api.skillsafe.ai/v1/badge/@josiahsiegel/nextjs-app-router/verified)](https://skillsafe.ai/skill/@josiahsiegel/nextjs-app-router/)
Installs badge
Installs badge
[![Installs badge](https://api.skillsafe.ai/v1/badge/@josiahsiegel/nextjs-app-router/installs)](https://skillsafe.ai/skill/@josiahsiegel/nextjs-app-router/)
Scan badge
Scan badge
[![Scan badge](https://api.skillsafe.ai/v1/badge/@josiahsiegel/nextjs-app-router/scan)](https://skillsafe.ai/skill/@josiahsiegel/nextjs-app-router/)
Eval pass rate badge
Eval pass rate
[![Eval pass rate badge](https://api.skillsafe.ai/v1/badge/@josiahsiegel/nextjs-app-router/eval)](https://skillsafe.ai/skill/@josiahsiegel/nextjs-app-router/)