Communitygithub.com

Next.js App Router 패턴

Next.js 14+ App Router, 서버 컴포넌트, 스트리밍, 병렬 라우트 및 고급 데이터 페칭을 마스터합니다. Next.js 애플리케이션을 구축하거나 SSR/SSG를 구현하거나 React 서버 컴포넌트를 최적화할 때 사용하세요.

Next.js App Router 패턴란 무엇인가요?

이 스킬은 Next.js 14+ App Router 아키텍처와 서버 컴포넌트를 중심으로 한 최신 풀스택 React 개발을 위한 포괄적인 패턴을 제공합니다. 서버 컴포넌트와 클라이언트 컴포넌트의 렌더링 모드, 파일 컨벤션, 데이터 페칭 전략, Suspense를 활용한 스트리밍, 병렬 및 인터셉팅 라우트, 서버 액션을 사용한 변이(mutation), 라우트 핸들러 및 SEO 메타데이터 생성 등 핵심 패턴을 다룹니다. 개발자는 이 스킬을 통해 App Router를 사용한 새로운 프로젝트 구축, Pages Router에서의 마이그레이션, 그리고 ISR, 태그 기반 캐시 무효화 등 고급 캐싱 전략을 손쉽게 적용할 수 있습니다. 주로 Claude Code와 같은 AI 에이전트 환경에서 사용되며, 실무에서 바로 활용 가능한 코드 예제와 함께 설명되어 있어 생산성을 크게 향상시킵니다.

지원 대상✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/Samueladewole/claude-skills/tree/HEAD/nextjs-app-router-patterns

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

Next.js App Router 패턴은(는) 무엇을 하나요?

Next.js 14+ App Router 아키텍처, 서버 컴포넌트 및 최신 풀스택 React 개발을 위한 포괄적인 패턴입니다.

이 스킬을 사용해야 할 때

  • App Router를 사용한 새로운 Next.js 애플리케이션 구축
  • Pages Router에서 App Router로 마이그레이션
  • 서버 컴포넌트 및 스트리밍 구현
  • 병렬 및 인터셉팅 라우트 설정
  • 데이터 페칭 및 캐싱 최적화
  • 서버 액션을 통한 풀스택 기능 구축

핵심 개념

1. 렌더링 모드

모드위치사용 시기
서버 컴포넌트서버 전용데이터 페칭, 무거운 연산, 비밀 정보
클라이언트 컴포넌트브라우저상호작용, 훅, 브라우저 API
정적(Static)빌드 시점거의 변경되지 않는 콘텐츠
동적(Dynamic)요청 시점개인화된 데이터 또는 실시간 데이터
스트리밍(Streaming)점진적큰 페이지, 느린 데이터 소스

2. 파일 컨벤션

app/
├── layout.tsx       # 공유 UI 래퍼
├── page.tsx         # 라우트 UI
├── loading.tsx      # 로딩 UI (Suspense)
├── error.tsx        # 오류 경계(Error boundary)
├── not-found.tsx    # 404 UI
├── route.ts         # API 엔드포인트
├── template.tsx     # 다시 마운트되는 레이아웃
├── default.tsx      # 병렬 라우트 폴백
└── opengraph-image.tsx  # OG 이미지 생성

빠른 시작

// app/layout.tsx
import { Inter } from 'next/font/google'
import { Providers } from './providers'

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

export const metadata = {
  title: { default: '내 앱', template: '%s | 내 앱' },
  description: 'Next.js App Router로 구축됨',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ko" suppressHydrationWarning>
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

// app/page.tsx - 기본적으로 서버 컴포넌트
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 }, // ISR: 매시간 재검증
  })
  return res.json()
}

export default async function HomePage() {
  const products = await getProducts()

  return (
    <main>
      <h1>제품 목록</h1>
      <ProductGrid products={products} />
    </main>
  )
}

패턴

패턴 1: 데이터 페칭이 있는 서버 컴포넌트

// app/products/page.tsx
import { Suspense } from 'react'
import { ProductList, ProductListSkeleton } from '@/components/products'
import { FilterSidebar } from '@/components/filters'

interface SearchParams {
  category?: string
  sort?: 'price' | 'name' | 'date'
  page?: string
}

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>
}) {
  const params = await searchParams

  return (
    <div className="flex gap-8">
      <FilterSidebar />
      <Suspense
        key={JSON.stringify(params)}
        fallback={<ProductListSkeleton />}
      >
        <ProductList
          category={params.category}
          sort={params.sort}
          page={Number(params.page) || 1}
        />
      </Suspense>
    </div>
  )
}

// components/products/ProductList.tsx - 서버 컴포넌트
async function getProducts(filters: ProductFilters) {
  const res = await fetch(
    `${process.env.API_URL}/products?${new URLSearchParams(filters)}`,
    { next: { tags: ['products'] } }
  )
  if (!res.ok) throw new Error('제품을 불러오는 데 실패했습니다')
  return res.json()
}

export async function ProductList({ category, sort, page }: ProductFilters) {
  const { products, totalPages } = await getProducts({ category, sort, page })

  return (
    <div>
      <div className="grid grid-cols-3 gap-4">
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
      <Pagination currentPage={page} totalPages={totalPages} />
    </div>
  )
}

패턴 2: 'use client'를 사용한 클라이언트 컴포넌트

// components/products/AddToCartButton.tsx
'use client'

import { useState, useTransition } from 'react'
import { addToCart } from '@/app/actions/cart'

export function AddToCartButton({ productId }: { productId: string }) {
  const [isPending, startTransition] = useTransition()
  const [error, setError] = useState<string | null>(null)

  const handleClick = () => {
    setError(null)
    startTransition(async () => {
      const result = await addToCart(productId)
      if (result.error) {
        setError(result.error)
      }
    })
  }

  return (
    <div>
      <button
        onClick={handleClick}
        disabled={isPending}
        className="btn-primary"
      >
        {isPending ? '추가 중...' : '장바구니에 담기'}
      </button>
      {error && <p className="text-red-500 text-sm">{error}</p>}
    </div>
  )
}

패턴 3: 서버 액션(Server Actions)

// app/actions/cart.ts
"use server";

import { revalidateTag } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export async function addToCart(productId: string) {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get("session")?.value;

  if (!sessionId) {
    redirect("/login");
  }

  try {
    await db.cart.upsert({
      where: { sessionId_productId: { sessionId, productId } },
      update: { quantity: { increment: 1 } },
      create: { sessionId, productId, quantity: 1 },
    });

    revalidateTag("cart");
    return { success: true };
  } catch (error) {
    return { error: "장바구니에 항목을 추가하지 못했습니다" };
  }
}

export async function checkout(formData: FormData) {
  const address = formData.get("address") as string;
  const payment = formData.get("payment") as string;

  // 검증
  if (!address || !payment) {
    return { error: "필수 필드가 누락되었습니다" };
  }

  // 주문 처리
  const order = await processOrder({ address, payment });

  // 확인 페이지로 리디렉션
  redirect(`/orders/${order.id}/confirmation`);
}

패턴 4: 병렬 라우트(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-grid">
      <main>{children}</main>
      <aside className="analytics-panel">{analytics}</aside>
      <aside className="team-panel">{team}</aside>
    </div>
  )
}

// app/dashboard/@analytics/page.tsx
export default async function AnalyticsSlot() {
  const stats = await getAnalytics()
  return <AnalyticsChart data={stats} />
}

// app/dashboard/@analytics/loading.tsx
export default function AnalyticsLoading() {
  return <ChartSkeleton />
}

// app/dashboard/@team/page.tsx
export default async function TeamSlot() {
  const members = await getTeamMembers()
  return <TeamList members={members} />
}

패턴 5: 인터셉팅 라우트 (모달 패턴)

// 파일 구조: 사진 모달
// app/
// ├── @modal/
// │   ├── (.)photos/[id]/page.tsx  # 인터셉트
// │   └── default.tsx
// ├── photos/
// │   └── [id]/page.tsx            # 전체 페이지
// └── layout.tsx

// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/Modal'
import { PhotoDetail } from '@/components/PhotoDetail'

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const photo = await getPhoto(id)

  return (
    <Modal>
      <PhotoDetail photo={photo} />
    </Modal>
  )
}

// app/photos/[id]/page.tsx - 전체 페이지 버전
export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const photo = await getPhoto(id)

  return (
    <div className="photo-page">
      <PhotoDetail photo={photo} />
      <RelatedPhotos photoId={id} />
    </div>
  )
}

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <html>
      <body>
        {children}
        {modal}
      </body>
    </html>
  )
}

패턴 6: Suspense를 사용한 스트리밍

// app/product/[id]/page.tsx
import { Suspense } from 'react'

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params

  // 이 데이터는 먼저 로드됨 (블로킹)
  const product = await getProduct(id)

  return (
    <div>
      {/* 즉시 렌더링 */}
      <ProductHeader product={product} />

      {/* 리뷰 스트리밍 */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={id} />
      </Suspense>

      {/* 추천 제품 스트리밍 */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations productId={id} />
      </Suspense>
    </div>
  )
}

// 이 컴포넌트들은 자신의 데이터를 가져옴
async function Reviews({ productId }: { productId: string }) {
  const reviews = await getReviews(productId) // 느린 API
  return <ReviewList reviews={reviews} />
}

async function Recommendations({ productId }: { productId: string }) {
  const products = await getRecommendations(productId) // ML 기반, 느림
  return <ProductCarousel products={products} />
}

패턴 7: 라우트 핸들러 (API 라우트)

// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const category = searchParams.get("category");

  const products = await db.product.findMany({
    where: category ? { category } : undefined,
    take: 20,
  });

  return NextResponse.json(products);
}

export async function POST(request: NextRequest) {
  const body = await request.json();

  const product = await db.product.create({
    data: body,
  });

  return NextResponse.json(product, { status: 201 });
}

// app/api/products/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });

  if (!product) {
    return NextResponse.json({ error: "제품을 찾을 수 없습니다" }, { status: 404 });
  }

  return NextResponse.json(product);
}

패턴 8: 메타데이터와 SEO

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

type Props = {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const product = await getProduct(slug)

  if (!product) return {}

  return {
    title: product.name,
    description: product.description,
    openGraph: {
      title: product.name,
      description: product.description,
      images: [{ url: product.image, width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title: product.name,
      description: product.description,
      images: [product.image],
    },
  }
}

export async function generateStaticParams() {
  const products = await db.product.findMany({ select: { slug: true } })
  return products.map((p) => ({ slug: p.slug }))
}

export default async function ProductPage({ params }: Props) {
  const { slug } = await params
  const product = await getProduct(slug)

  if (!product) notFound()

  return <ProductDetail product={product} />
}

캐싱 전략

데이터 캐시

// 캐시 없음 (항상 최신)
fetch(url, { cache: "no-store" });

// 영구 캐시 (정적)
fetch(url, { cache: "force-cache" });

// ISR - 60초 후 재검증
fetch(url, { next: { revalidate: 60 } });

// 태그 기반 무효화
fetch(url, { next: { tags: ["products"] } });

// 서버 액션을 통한 무효화
("use server");
import { revalidateTag, revalidatePath } from "next/cache";

export async function updateProduct(id: string, data: ProductData) {
  await db.product.update({ where: { id }, data });
  revalidateTag("products");
  revalidatePath("/products");
}

모범 사례

해야 할 일

  • 서버 컴포넌트로 시작 - 필요한 경우에만 'use client' 추가
  • 데이터 페칭을 함께 배치 - 데이터를 사용하는 곳에서 페칭
  • Suspense 경계 사용 - 느린 데이터에는 스트리밍 활성화
  • 병렬 라우트 활용 - 독립적인 로딩 상태
  • 서버 액션 사용 - 점진적 향상(progressive enhancement)이 있는 변이에 사용

하지 말아야 할 일

  • 직렬화할 수 없는 데이터 전달 금지 - 서버 → 클라이언트 경계 제한
  • 서버 컴포넌트에서 훅 사용 금지 - useState, useEffect 사용 불가
  • 클라이언트 컴포넌트에서 데이터 페칭 금지 - 서버 컴포넌트 또는 React Query 사용
  • 레이아웃 과도 중첩 금지 - 각 레이아웃은 컴포넌트 트리에 추가됨
  • 로딩 상태 무시 금지 - 항상 loading.tsx 또는 Suspense 제공

리소스

Individual skills in this repo

This repo contains 5 individual skills — each has its own dedicated page.

관련 스킬