React

 

✅ generateStaticParams

동적 라우트([id].tsx 같은 파일)에서 정적 경로를 미리 생성할 때 사용합니다.
getStaticPaths(Next 12 이하)와 유사한 기능입니다.

사용 목적

  • 정적 생성(SSG, Static Site Generation) 방식으로 페이지를 미리 빌드

  • 사용자가 방문하기 전에 미리 HTML을 생성하여 성능 최적화

  • params 값을 미리 정해놓고 해당 값으로 동적 페이지를 정적으로 생성

 

사용 예시

// app/products/[id]/page.tsx

export async function generateStaticParams() {
  const products = await fetch("https://api.example.com/products").then(res => res.json());

  return products.map((product) => ({
    id: product.id.toString(), // 문자열 변환 필수
  }));
}

export default function ProductPage({ params }: { params: { id: string } }) {
  return <h1>Product ID: {params.id}</h1>;
}

설명

  • generateStaticParams는 미리 정적 페이지를 만들 ID 리스트를 반환

  • 해당 ID를 기반으로 Next.js는 products/[id] 페이지를 미리 생성

  • 추가적인 요청이 필요 없으므로 성능이 향상됨

  • API 호출이 불가능할 경우 notFound()를 반환하면 404 처리 가능

 

 

 

✅ generateMetadata

페이지별 SEO 메타데이터(타이틀, 설명, 오픈그래프 태그 등)를 동적으로 설정할 때 사용합니다.

 사용 목적

  • 동적 메타데이터 적용 (예: 블로그 글 제목을 동적으로 설정)

  • SEO 최적화 (title, description, og:image 설정 가능)

 사용 예시

// app/products/[id]/page.tsx

export async function generateMetadata({ params }: { params: { id: string } }) {
  const product = await fetch(`https://api.example.com/products/${params.id}`).then(res => res.json());

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

export default function ProductPage({ params }: { params: { id: string } }) {
  return <h1>Product ID: {params.id}</h1>;
}

 설명

  • generateMetadata는 params.id를 받아서 해당 제품의 데이터를 불러옴

  • 불러온 데이터로 title, description, openGraph 등을 동적으로 설정

  • 페이지별로 SEO 최적화가 가능해짐

 

 

 

 

 

 

 사용 예시

 

1.반드시  다음과 같이 Promise 로 타입을 정의 할것

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

 

2.  파라미터도  반드시 await 를 사용할것

const { slug } =await params;

 

import React from "react";
import { Heading } from "@/components/Heading";
import { getReview, getSlugs } from "@/lib/reviews";
import Image from "next/image";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Metadata } from "next";
import ShareButtons from "@/components/ShareButtons";

export async function generateStaticParams() {
  const slugs = await getSlugs();
  return slugs.map((slug) => ({ slug }));
}

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

export async function generateMetadata({params}:Props): Promise<Metadata> {
  const {slug}=await  params;
  const review = await getReview(slug);

  return {
    title: review.title,
    description: review.body.slice(0, 150) + "…",
    openGraph: {
      title: review.title,
      description: review.body.slice(0, 150) + "…",
      images: [{ url: `${process.env.NEXT_PUBLIC_SITE_URL}${review.image}` }],
      type: "article",
    },
    twitter: {
      card: "summary_large_image",
      title: review.title,
      description: review.body.slice(0, 150) + "…",
      images: [`${process.env.NEXT_PUBLIC_SITE_URL}${review.image}`],
    },
    metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"),
  };

}



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

const ReviewPage: React.FC<ReviewPageProps> = async ({ params }) => {
  const { slug } =await params;
  const review = await getReview(slug);

  return (
    <div className="max-w-4xl mx-auto px-4 py-6">
      <Card className="shadow-lg overflow-hidden">
        <CardHeader className="p-0">
          <Image
            src={review.image}
            alt={`영화 리뷰: ${review.title}`}
            width={640}
            height={360}
            className="w-full h-auto object-cover rounded-t-lg"
          />
        </CardHeader>
        <CardContent className="p-6">
          <Heading className="text-2xl font-bold mb-2 text-center">
            {review.title}
          </Heading>
          <p className="text-sm text-gray-500 italic text-center pb-4">{review.date}</p>

          <ShareButtons />


          <Separator className="mb-4" />
          <article
            dangerouslySetInnerHTML={{ __html: review.body }}
            className="prose prose-slate mx-auto"
          />
          <Button  asChild className="mt-5 block mx-auto" variant="secondary">
            <Link href="/reviews">목록으로</Link>
          </Button>
        </CardContent>
      </Card>
    </div>
  );
};

export default ReviewPage;

 

 출력

<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>할로 나이트(Hollow Knight)</title>
<meta name="description"
    content="《할로 나이트》(Hollow Knight)는 호주의 게임 개발사 팀 체리가 제작한 액션 게임이다. 
    일반 2D 액션 게임에 비선형 맵 구조를 접합한 일명 '메트로배니아' 게임으로, 2014년 11월 크라우드펀딩 
    사이트 킥스타터에 공개돼 약 57,000 호주 달러를 모금하…">
<meta property="og:title" content="할로 나이트(Hollow Knight)">
<meta property="og:description"
    content="《할로 나이트》(Hollow Knight)는 호주의 게임 개발사 팀 체리가 제작한 액션 게임이다. 
    일반 2D 액션 게임에 비선형 맵 구조를 접합한 일명 '메트로배니아' 게임으로, 2014년 11월 크라우드펀딩 
    사이트 킥스타터에 공개돼 약 57,000 호주 달러를 모금하…">
<meta property="og:image" content="http://localhost:3000/undefined/images/hollow-knight.jpg">
<meta property="og:type" content="article">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="할로 나이트(Hollow Knight)">
<meta name="twitter:description"
    content="《할로 나이트》(Hollow Knight)는 호주의 게임 개발사 팀 체리가 제작한 액션 게임이다. 
    일반 2D 액션 게임에 비선형 맵 구조를 접합한 일명 '메트로배니아' 게임으로, 
    2014년 11월 크라우드펀딩 사이트 킥스타터에 공개돼 약 57,000 호주 달러를 모금하…">
<meta name="twitter:image" content="http://localhost:3000/undefined/images/hollow-knight.jpg">
<link rel="icon" href="/favicon.ico?favicon.45db1c09.ico" sizes="256x256" type="image/x-icon">

 

 

 

 

 

 

 

1.정적인 페이지

 

/src/app/reviews/[slug]/page.tsx 경로의 상세 페이지는 빌드 시 정적인 HTML 파일로 생성되므로, 페이지가 빠르게 로딩되고 별도의 캐시 없이도 성능이 뛰어납니다

. 또한, generateMetadata 함수를 통해 SEO에 최적화된 메타데이터를 페이지에 포함시킬 수 있습니다.

하지만 이 방식은 정적인 데이터에 최적화되어 있기 때문에, 페이지 로딩 시점에 매번 다른 데이터를 받아야 하는 동적인 데이터 처리에는 한계가 있습니다.

따라서, 클라이언트 측에서 React Query 등을 활용하여 동적으로 데이터를 가져올 수는 있으나,

generateMetadata처럼 서버에서 동작하는 함수와는 분리되어야 합니다. 서버 컴포넌트에서는 빌드 시점에 필요한 데이터만을 다루고,

클라이언트 컴포넌트에서는 사용자 상호작용에 따라 동적으로 데이터를 가져오는 구조로 구분하는 것이 바람직합니다.

 

 

✅ 핵심 요약:

  • generateMetadata → 정적 데이터 기반 SEO 처리 (서버에서 실행)

  • React Query → 동적 데이터 처리 (클라이언트에서 실행)

  • 이 둘은 역할과 실행 시점이 다르므로 분리해서 사용하는 것이 올바른 설계입니다.

 

 

 

 

1)/src/app/reviews/[slug]/page.tsx

import React from "react";
import { Heading } from "@/components/Heading";
import { getReview ,getSlugs} from "@/lib/reviews";
import Image from "next/image";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Metadata } from "next";
import ShareButtons from "@/components/ShareButtons";

export async function generateStaticParams() {
  const slugs = await getSlugs();
  return slugs.map((slug: string) => ({ slug }));
}

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

export async function generateMetadata({params}:Props): Promise<Metadata> {
  const {slug}=await  params;
  const review = await getReview(slug);

  return {
    title: review.title,
    description: review.body.slice(0, 150) + "…",
    openGraph: {
      title: review.title,
      description: review.body.slice(0, 150) + "…",
      images: [{ url: `${process.env.NEXT_PUBLIC_SITE_URL}${review.image}` }],
      type: "article",
    },
    twitter: {
      card: "summary_large_image",
      title: review.title,
      description: review.body.slice(0, 150) + "…",
      images: [`${process.env.NEXT_PUBLIC_SITE_URL}${review.image}`],
    },
    metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL||""), 
  };

}



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

const ReviewPage: React.FC<ReviewPageProps> = async ({ params }) => {
  const { slug } =await params;
  const review = await getReview(slug);



  return (
    <div className="max-w-4xl mx-auto px-4 py-6">
      <Card className="shadow-lg overflow-hidden">
        <CardHeader className="p-0">
          <Image
            src={review.image}
            alt={`영화 리뷰: ${review.title}`}
            width={640}
            height={360}
            className="w-full h-auto object-cover rounded-t-lg"
          />
        </CardHeader>
        <CardContent className="p-6">
          <Heading className="text-2xl font-bold mb-2 text-center">
            {review.title}
          </Heading>
          <p className="text-sm text-gray-500 italic text-center pb-4">{review.date}</p>

          <ShareButtons />


          <Separator className="mb-4" />
          <article
            dangerouslySetInnerHTML={{ __html: review.body }}
            className="prose prose-slate mx-auto"
          />
          <Button  asChild className="mt-5 block mx-auto" variant="secondary">
            <Link href="/reviews">목록으로</Link>
          </Button>
        </CardContent>
      </Card>
    </div>
  );
};

export default ReviewPage;

 

 

 

 

2)/src/lib/reviews.ts

import { marked } from "marked";
import { notFound } from "next/navigation";

import qs from "qs";


const  CMS_URL = process.env.NEXT_PUBLIC_CMS_URL || "http://localhost:1337";

export interface GetReviewData {
  id: number;
  attributes: {
    slug: string;
    title: string;
    subtitle: string;
    publishedAt: string; // ISO date string
    image: {
      data: {
        id: number;
        attributes: {
          url: string;
        };
      };
    };
  };
}

export type Review ={
  slug: string,
  title: string,
  date: string,
  image: string,
  body: string,
  subtitle: string,
  id: number;
}

export async function fetchReviews(parameters:object) {    
  const url = `${CMS_URL}/api/reviews?`
         + qs.stringify(parameters, { encodeValuesOnly: true });    
    //console.log(" [fetchReviews] url: ", url);
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`CMS returned ${response.status} for ${url} `);
    }
    return await response.json();
}


function toReview(item :GetReviewData){
  const {id} = item;
  const {attributes} = item;
  const {slug, title, subtitle, publishedAt} = attributes;
  const image = CMS_URL+attributes.image.data.attributes.url;
  const date=publishedAt.slice(0,"yyyy-mm-dd".length);
  return {id, slug, title, subtitle, date, image};
}


export async function getReview(slug:string) :Promise<Review> {
  try {
    const {data} = await fetchReviews({                        
      filters:{slug: {$eq: slug}},
      fields: ["slug", "title", "subtitle" ,"publishedAt", "body"],
      populate: { image: { fields: ["url"] } },
      pagination: { pageSize:1, withCount: false },
    });

    const item = data[0];
    return{
      ...toReview(item),      
      body: await marked(item.attributes.body),
    }   
  } catch (error) {
    console.error("Error fetching review:", error);
    notFound();
  }     
}

export async function getFeaturedReview(): Promise<Review> {
  const reviews = await getReviews();
  return reviews[0];
}


export async function getReviews() {        
    const {data} = await fetchReviews({            
      fields: ["slug", "title", "subtitle" ,"publishedAt"],
      populate: { image: { fields: ["url"] } },
      sort: ["publishedAt:desc"],
      pagination: { pageSize:6}
    });
    return data.map((item :GetReviewData) => toReview(item));  
}


export async function getSlugs() {
  const {data} = await fetchReviews({
    fields: ["slug"],
    sort: ["publishedAt:desc"],    
    pagination: { pageSize: 1000 },
  });
  return data.map((item :GetReviewData) =>item.attributes.slug );  
}




 

 

 

 

 

 

 

 

2.동적인 페이지 SEO 최적화 작업 및 리액트 쿼리 사용

 

소스 : https://github.dev/braverokmc79/nextjs-review

 

app/
├── components/             ← 공통 shadcn 컴포넌트들 (Button, Separator 등)
│
├── reviews/
│   ├── components/
│   │   └── review/
│   │       ├── ReviewContent.tsx
│   │       ├── ReviewSkeleton.tsx
│   │       └── ReviewError.tsx
│   └── [slug]/
│       └── page.tsx        ← generateMetadata 포함

 

✅ 이 구조의 장점 요약

 

 

 

구현 구조

1) app/reviews/[slug]/page.tsx         → generateMetadata 포함, SEO 처리
2) app/reviews/components/review/ReviewContent.tsx → React Query + 스켈레톤 + 에러 처리
3) app/reviews/components/ReviewSkeleton.tsx
4) app/reviews/components/ReviewError.tsx

 

 

1)  /src/app/reviews/[slug]/page.tsx

import { getReview } from "@/lib/reviews";
import { Metadata } from "next";
import ReviewContent from "@/components/review/ReviewContent";

export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const review = await getReview(params.slug);

  return {
    title: review.title,
    description: review.body.slice(0, 150) + "…",
    openGraph: {
      title: review.title,
      description: review.body.slice(0, 150) + "…",
      images: [{ url: `${process.env.NEXT_PUBLIC_SITE_URL}${review.image}` }],
      type: "article",
    },
    twitter: {
      card: "summary_large_image",
      title: review.title,
      description: review.body.slice(0, 150) + "…",
      images: [`${process.env.NEXT_PUBLIC_SITE_URL}${review.image}`],
    },
    metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || ""),
  };
}

export default function ReviewPage({ params }: { params: { slug: string } }) {
  return <ReviewContent slug={params.slug} />;
}

 

 

2)/src/app/reviews/components/review/ReviewContent.tsx

"use client";

import { useQuery } from "@tanstack/react-query";
import { getReview } from "@/lib/reviews";
import ReviewSkeleton from "./ReviewSkeleton";
import ReviewError from "./ReviewError";

import { Heading } from "@/components/Heading";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import ShareButtons from "@/components/ShareButtons";
import Link from "next/link";
import Image from "next/image";

type Props = {
  slug: string;
};

export default function ReviewContent({ slug }: Props) {
  const { data: review, isLoading, error } = useQuery({
    queryKey: ["review", slug],
    queryFn: () => getReview(slug),
    staleTime: 1000 * 60 * 5,
  });

  if (isLoading) return <ReviewSkeleton />;
  if (error || !review) return <ReviewError />;

  return (
    <div className="max-w-4xl mx-auto px-4 py-6">
      <Card className="shadow-lg overflow-hidden">
        <CardHeader className="p-0">
          <Image
            src={review.image}
            alt={`영화 리뷰: ${review.title}`}
            width={640}
            height={360}
            className="w-full h-auto object-cover rounded-t-lg"
          />
        </CardHeader>
        <CardContent className="p-6">
          <Heading className="text-2xl font-bold mb-2 text-center">{review.title}</Heading>
          <p className="text-sm text-gray-500 italic text-center pb-4">{review.date}</p>

          <ShareButtons />

          <Separator className="mb-4" />
          <article
            dangerouslySetInnerHTML={{ __html: review.body }}
            className="prose prose-slate mx-auto"
          />
          <Button asChild className="mt-5 block mx-auto" variant="secondary">
            <Link href="/reviews">목록으로</Link>
          </Button>
        </CardContent>
      </Card>
    </div>
  );
}

 

 

3) 스켈레톤 로딩 

review/ components//ReviewSkeleton.tsx

export default function ReviewSkeleton() {
  return (
    <div className="max-w-4xl mx-auto px-4 py-6 animate-pulse">
      <div className="bg-gray-200 h-[200px] w-full rounded-lg mb-4" />
      <div className="bg-gray-300 h-6 w-1/2 mx-auto mb-2 rounded" />
      <div className="bg-gray-200 h-4 w-1/4 mx-auto mb-4 rounded" />
      <div className="bg-gray-100 h-4 w-full mb-2 rounded" />
      <div className="bg-gray-100 h-4 w-5/6 mb-2 rounded" />
      <div className="bg-gray-100 h-4 w-3/4 mb-2 rounded" />
    </div>
  );
}

 

4) 에러페이지

export default function ReviewError() {
  return (
    <div className="max-w-2xl mx-auto text-center py-12 text-red-600">
      <h2 className="text-lg font-semibold mb-2">리뷰를 불러오지 못했습니다</h2>
      <p>일시적인 오류일 수 있습니다. 잠시 후 다시 시도해주세요.</p>
    </div>
  );
}

 

 

⭕추가 설정: React Query Provider

app/layout.tsx 혹은 _app.tsx에서 QueryClientProvider가 포함되어 있어야 작동합니다.

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useState } from "react";

export default function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

그리고 layout.tsx에서 <Providers>로 감싸주기:
 

import Providers from "@/components/Providers";

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

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

자기 자신에 관해서 모두 알고 있는 사람은 다른 사람에 관해서도 모두 알고 있다. -오스카 와일드

댓글 ( 0)

댓글 남기기

작성