๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ

๐Ÿ‘ฉ๐Ÿป‍๐Ÿ’ป dev

[Nextjs] Dynamic metadata tag ์ƒ์„ฑํ•˜๊ธฐ (SEO ์ง€ํ‘œ 54 โžก๏ธ 91)

nextjs๋กœ ํ•ด๋ณด๊ณ  ์‹ถ์—ˆ๋˜ ๊ฒƒ์ด ์žˆ๋‹ค๋ฉด Metadata API ๋ฅผ ํ™œ์šฉํ•ด์„œ ๊ฐ„๋‹จํ•˜๊ฒŒ metadata tag๋ฅผ ๋‹ฌ์•„์ฃผ๋Š” ๊ฒŒ ์•„๋‹๊นŒ ์‹ถ๋‹ค.
์˜ค๋Š˜์€ ๊ทธ ์ค‘ ํŽ˜์ด์ง€ ํŒŒ๋žŒ์— ๋”ฐ๋ผ ๋‹ค์ด๋‚ด๋ฏนํ•˜๊ฒŒ ๋ฉ”ํƒ€ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ๋‹ค๋ฃจ๊ณ ์ž ํ•œ๋‹ค.

๋ฉ”ํƒ€ํƒœ๊ทธ๋ฅผ ๋“ฑ๋กํ•ด์ฃผ๋‹ˆ ํŽ˜์ด์ง€ ์ œ๋ชฉ์ด ํƒญ์— ํ‘œ์‹œ๋œ๋‹ค.

Tripie ์•ฑ์€ ํฌ๊ฒŒ ๋‘ ๊ฐ€์ง€ ์„œ๋น„์Šค๋ฅผ ์ œ๊ณตํ•˜๋Š”๋ฐ, 

  1. ํŠธ๋ฆฌํ”Œ์—์„œ ์Šคํฌ๋ž˜ํ•‘ํ•œ ์—ฌํ–‰์ง€์— ๋Œ€ํ•œ ํŒ, ์ง€์—ญ ์ •๋ณด๋‚˜ ์‹๋‹น ์ •๋ณด, ์—ฌํ–‰ ์ผ์ • ํŒ ๋“ฑ์„ ์—ด๋žŒํ•  ์ˆ˜ ์žˆ๋‹ค.
  2. ์ฑ—์ง€ํ”ผํ‹ฐ ๊ธฐ๋ฐ˜์œผ๋กœ ์‚ฌ์šฉ์ž์˜ ๊ธฐํ˜ธ์— ๋”ฐ๋ผ ์—ฌํ–‰์ผ์ •์„ ์ž‘์„ฑํ•ด ์ค€๋‹ค.  

์ด ์ค‘ ๋ฉ”ํƒ€ํƒœ๊ทธ๋กœ ์‚ผ์„ ๋ฐ์ดํ„ฐ๋“ค์ด ๋ชจ๋‘ ์ค€๋น„๋œ ์ฒซ ๋ฒˆ์งธ์ธ ์—ฌํ–‰์ง€ ์ •๋ณด์— ๋Œ€ํ•œ ํŽ˜์ด์ง€๋“ค์— ๋ฉ”ํƒ€ํƒœ๊ทธ๋ฅผ ์ถ”๊ฐ€ํ•ด์ฃผ๊ณ ์ž ํ•œ๋‹ค. 

๊ณต์‹๋ฌธ์„œ๋ฅผ ์‚ดํŽด๋ณด๋ฉด, ๋ฉ”ํƒ€ํƒœ๊ทธ๋ฅผ ๋‹ฌ ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์€ ๋‘๊ฐ€์ง€๋‹ค.

  • Config ๊ธฐ๋ฐ˜ : static metadata object๋‚˜ dynamic generateMetadata ํ•จ์ˆ˜๋ฅผ layout.js๋‚˜ page.js ํŒŒ์ผ์— exportํ•˜๋Š” ๋ฐฉ์‹
  • File ๊ธฐ๋ฐ˜ : static์ด๋‚˜ dynamic ํ•˜๊ฒŒ ์ƒ์„ฑ๋œ ์•„๋ž˜์˜ ํŠน์ • ํŒŒ์ผ๋“ค์„ route segment์— ์ถ”๊ฐ€๋Š” ๋ฐฉ์‹

์œ„์˜ ๋ช…์‹œ๋œ ํŒŒ์ผ๋“ค์„ ํ™œ์šฉํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ฏ€๋กœ Config ๊ธฐ๋ฐ˜์˜ generateMetadata ํ•จ์ˆ˜๋ฅผ exportํ•˜๋Š” ๋ฐฉ์‹์„ ํƒํ•œ๋‹ค.

โ€ผ ์—ฌ๊ธฐ์„œ ์ค‘์š”ํ•œ ์ โ€ผ
generateMetadata์œผ๋กœ ์ œ๊ณต๋œ static์ด๋‚˜ dynamic ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋ชจ๋‘ ServerComponent ์—๋งŒ ์ง€์›๋˜๊ณ ,
Fetch ์š”์ฒญ์€ generateMetadata, generateStaticParams, Layouts, Pages, and Server Components ์‚ฌ์ด์— ๋ชจ๋‘ ์ž๋™์ ์œผ๋กœ memoize๋œ๋‹ค.
๋งŒ์•ฝ fetch ๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค๋ฉด react cache๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. nextjs๋Š” UI๋ฅผ ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ stream ํ•˜๊ธฐ ์ „์— generateMetadata ์•ˆ์˜ ๋ฐ์ดํ„ฐ fetch ์™„๋ฃŒ๋ฅผ ๊ธฐ๋‹ค๋ฆฌ๊ธฐ ๋•Œ๋ฌธ์—, stream๋˜๋Š” ์‘๋‹ต์ด <head> ํƒœ๊ทธ๋ฅผ ํฌํ•จ๋  ์ˆ˜ ์žˆ๋„๋ก ๋ณด์žฅํ•œ๋‹ค.

 

tripie์˜ ์•„ํ‹ฐํด ์ข…๋ฅ˜๋Š” 3๊ฐ€์ง€๋กœ ๋‚˜๋‰˜์–ด articles/attractions/restaurant์ด ์žˆ๋‹ค. 

์˜ˆ๋ฅผ ๋“ค์–ด  https://tripie-mauve.vercel.app/regions/23c5965b-01ad-486b-a694-a2ced15f245c/articles/2c05edb7-0e01-4b16-99a1-ce304b4fa996๋ฅผ ์‚ดํŽด๋ณด๋ฉด

 

๋‚˜๊ฐ€์‚ฌํ‚ค 3๋ฐ• 4์ผ ์—ฌํ–‰ ์ฝ”์Šค

์—ฌํ–‰์ž๋“ค์—๊ฒŒ ์ถ”์ฒœํ•˜๋Š” ์ตœ์ ์˜ ์ผ์ •๊ณผ ๋ฃจํŠธ

tripie-mauve.vercel.app

๊ทธ๋ฆฌ๊ณ  ํŽ˜์ด์ง€ ๊ตฌ์กฐ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

/regions
|       /[regionId] (์ง€์—ญ ID, 23c5965b-01ad-486b-a694-a2ced15f245c)
|          |       [articles] (์•„ํ‹ฐํด ID, e96c2b79-849c-453f-8c0c-7950dc2754c7)
|          |          ใ„ด page.tsx
|          |       [attractions] (๊ด€๊ด‘๋ช…์†Œ ID)
|          |           ใ„ด page.tsx
|          |       [restaurant] (์‹๋‹น ID)
|          |           ใ„ด page.tsx
|          |        [location] (๊ฒ€์ƒ‰  ID, regions/์ผ๋ณธ) 
|          |            ใ„ด page.tsx
|          ใ„ด page.tsx
ใ„ด page.tsx

 

attraction๊ณผ restaurant๋Š” ํƒ€์ž…์ด ์œ ์‚ฌํ•˜์ง€๋งŒ attraction์€ ์ข€ ๋‹ค๋ฅธ ํ˜•ํƒœ๋‹ค.

๋จผ์ € /articles๋ฅผ ์‚ดํŽด๋ณด๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ฐ์ดํ„ฐ ํƒ€์ž…์ด๊ณ ,

export type ArticleData = {
  body: BodyItemProps[];
  header: null;
  metadata: Metadata;
  metadataContents: MetaDataContents;
  placeId: string;
  seoMetadata: null;
  id: string;
};

export type MetaDataContents = {
  image: TripieMetaImage;
  title?: string;
  description?: string;
};

 

์ด์ค‘ metadataContents์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ฌ์šฉํ•  ์˜ˆ์ •์ด๋‹ค. ๋ฉ”ํƒ€ํƒœ๊ทธ์— ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฐ’๋“ค์€ ๊ณต์‹ ๋ฌธ์„œ์—์„œ ํ™•์ธ ๊ฐ€๋Šฅํ•˜๋‹ค.

์—ฌ๊ธฐ์„œ ๊ถ๊ธˆํ•œ ์  og:title์ด๋ž‘ ๊ทธ๋ƒฅ title์ด ์žˆ๋Š”๋ฐ ์ฐจ์ด๊ฐ€ ๋ญ์ง€?

๐Ÿ”น 1. Open Graph Meta Tags (og:)

  • Purpose: Used for social media sharing (Facebook, Twitter, LinkedIn, etc.).
  • Effect: Controls how links appear when shared (title, description, image, etc.).
  • Prefix: Uses og: (e.g., og:title, og:image).

๐Ÿ”น 2. Regular Meta Tags (meta)

  • Purpose: Used for SEO and general metadata (title, description, viewport settings, etc.).
  • Effect: Helps search engines like Google understand and index content.
  • Prefix: No prefix (e.g., meta name="description").

์ •๋ฆฌํ•˜์ž๋ฉด,

open graph ํƒœ๊ทธ๋Š” (og:)๋ฅผ ๋ถ™์—ฌ์„œ 'property="og:image"' ํ˜•ํƒœ๋กœ ์‚ฌ์šฉ๋˜๋ฉฐ, ์†Œ์…œ ๋ฏธ๋””์–ด์— ๊ณต์œ ๋  ๋งํฌ๊ฐ€ ๋ณด์ด๋Š” ๋ชจ์Šต์— ๊ด€์—ฌํ•˜๊ณ ,

์ผ๋ฐ˜ meta ํƒœ๊ทธ๋Š” name="title"๊ณผ ๊ฐ™์ด ์‚ฌ์šฉ๋˜๋ฉฐ, ๊ตฌ๊ธ€๊ณผ ๊ฐ™์ด ๊ฒ€์ƒ‰ ์—”์ง„์ด ์ฝ˜ํ…์ธ ๋ฅผ ์ดํ•ดํ•˜๊ณ  ์ธ๋ฑ์‹ฑ ํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉ๋˜์–ด SEO ํ–ฅ์ƒ์— ๋„์›€์„ ์ค€๋‹ค.

 

์ฆ‰, ๊ฒ€์ƒ‰์—”์ง„์— ๋…ธ์ถœ์‹œํ‚ค๊ณ  ์‹ถ๊ณ , ์†Œ์…œ ๋ฏธ๋””์–ด๋กœ ๊ณต์œ ํ•  ๋•Œ ๋งํฌ ์นด๋“œ๋„ ๊พธ๋ฉฐ์„œ ๋ณด์ด๊ณ  ์‹ถ๋‹ค๋ฉด ๋‘˜ ๋‹ค ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ์ด๋‹ค.

// https://nextjs.org/docs/app/building-your-application/optimizing/metadata
type Props = {
  params: Promise<{ regionId: string; articleId: string }>;
};

export async function generateMetadata({ params }: Props, parent: ResolvingMetadata): Promise<Metadata> {
  // read route params
  const regionId = (await params).regionId;
  const articleId = (await params).articleId;

  const { data } = await getArticleDetail('article', regionId, articleId);

  const previousImages = (await parent).openGraph?.images || [];
  const description = data?.metadataContents?.description ?? '';
  const title = 'โœˆ๏ธTripie | '+ data?.metadataContents?.title ?? '';

  return {
    title,
    description,
    openGraph: {
      images: [data?.metadataContents.image.sizes?.full?.url ?? '', ...previousImages],
      type: 'website',
      url: `${API.BASE_URL}${ROUTE.REGIONS.href}/${regionId}/articles/${articleId}`,
      title,
      description,
      siteName: 'Tripie',
    },
  };
}

 

๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ž‘์„ฑํ•ด ์ฃผ๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ฐฐํฌ๋œ ์‚ฌ์ดํŠธ์˜ elements ํƒญ์„ ์—ด์–ด๋ด์„œ ๋ฉ”ํƒ€ํƒœ๊ทธ๊ฐ€ ์ ์šฉ๋œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

 

์ด๋ฒˆ์—๋Š” restaurants์™€ attractions๋ฅผ ์‚ดํŽด๋ณด๊ณ ์ž ํ•œ๋‹ค. ์œ ์‚ฌํ•˜๊ฒŒ generateMetadata๋ฅผ ๊ฐ ๊ฒฝ๋กœ [restaurants]/[articleId]/page.tsx ๊ทธ๋ฆฌ๊ณ  [attractions]/[articleId]/page.tsx์— ์ถ”๊ฐ€ํ•ด ์ฃผ๋ฉด ๋œ๋‹ค. 

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

export async function generateMetadata({ params }: Props, parent: ResolvingMetadata): Promise<Metadata> {
  const regionId = (await params).regionId;
  const articleId = (await params).articleId;

  const { data } = await getArticleDetail('retaurant', regionId, articleId);

  const previousImages = (await parent).openGraph?.images || [];
  const title =
    data?.source.names.primary ?? data?.source.names.ko ?? data?.source.names.en ?? data?.source.names.local ?? '';
  const description = data?.source?.comment ?? '';

  return {
    title: `โœˆ๏ธTripie | ${title}`,
    description,
    openGraph: {
      images: [data?.source.image.sizes.full.url ?? '', ...previousImages],
      type: 'website',
      url: `${API.BASE_URL}${ROUTE.REGIONS.href}/restaurant/${regionId}/${articleId}`,
      title: `โœˆ๏ธTripie | ${title}`,
      description,
      siteName: 'Tripie',
    },
  };
}

attractions์™€ restaurants์˜ ๋ฉ”ํƒ€ ํƒœ๊ทธ

https://www.opengraph.xyz/url/https%3A%2F%2Ftripie-mauve.vercel.app%2Fregions%2F23c5965b-01ad-486b-a694-a2ced15f245c%2Farticles%2F8c0aae20-b057-49c9-8420-cb8026c4a41a ์—์„œ ์นด์นด์˜คํ†ก ์ด์™ธ์˜ ์†Œ์…œ ๋ฏธ๋””์–ด๋กœ ๊ฐ๊ฐ ๊ณต์œ ํ–ˆ์„ ๋•Œ ๋ชจ์Šต์„ ํ™•์ธํ•˜๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค. 


(+์ถ”๊ฐ€) SEO ํ–ฅ์ƒ์— ๋„์›€์„ ์ฃผ๋Š”๊ฐ€ ํ™•์ธํ•˜๋ ค๊ณ  lighthouse๋กœ ์ง€ํ‘œ๋ฅผ ์ธก์ •ํ•ด ๋ณด๋‹ˆ, ํ›Œ์ฉ ๋›ฐ์—ˆ๋‹ค!

SEO ์ ์šฉ ์ „ํ›„


TODO'S

-์ด๋ ‡๊ฒŒ ํ•˜๋‹ˆ ๋ฉ”ํƒ€ ํƒœ๊ทธ ์ถ”๊ฐ€๋Š” ๋˜์—ˆ๋‹ค! ๋‹ค๋งŒ ์ค‘๋ณต๋˜๋Š” ๋ถ€๋ถ„๋“ค์„ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์ด ์—†๋‚˜ ์ฐพ์•„๋ณด๋‹ˆ, ์ƒ์œ„์˜ page ๋‚˜ layout์˜ ๋ฉ”ํƒ€ํƒœ๊ทธ๋ฅผ ํ•˜์œ„์˜ ํŒŒ์ผ์— ์ž‘์„ฑํ•˜๋ฉด ๋ฎ์–ด์“ธ ์ˆ˜ ์žˆ๋‹ค๋Š” ์ ์ด๋‹ค. ํ•˜์ง€๋งŒ ํ˜„์žฌ ํŒŒ์ผ๋“ค์˜ ๊ตฌ์กฐ์™€๋Š” ๋ถ€์ ํ•ฉํ•˜๋ฏ€๋กœ ์ข€ ๋” ๊ณ ๋ฏผ์„ ํ•˜๊ณ  ๊ฐœ์„  ๋ฐฉ์•ˆ์„ ์ฐพ์•„๋ด์•ผ๊ฒ ๋‹ค. (ํ•ด๋‹น ํฌ์ŠคํŠธ์—์„œ ์ ์ ˆํ•˜๊ฒŒ ๋ฐ˜์˜ํ•ด๋ณด์•˜๋‹ค)

- ํŽ˜์ด์ง€๋ณ„๋กœ ์ ์ˆ˜๊ฐ€ ์™”๋‹ค ๊ฐ”๋‹ค ํ•˜๋Š”๋ฐ, ํ™•์‹คํžˆ ๋„์ž… ์ด์ „๋ณด๋‹ค๋Š” ๋†’๋‹ค. ์•„์ง ์ตœ์ ํ™” ๋‹จ๊ณ„๋Š” ๋„์ž… ์ „์ด๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋“ค์„ ๋จผ์ € ์งœ๊ณ , ๋ฆฌํŽ™ํ† ๋ง์„ ์ง„ํ–‰ ํ›„, ๊ทธ๋‹ค์Œ์— ๋„์ž…ํ•  ์˜ˆ์ •์ด๋‹ค.


REFERENCES

 

https://nextjs.org/docs/app/building-your-application/optimizing/metadata

 

Optimizing: Metadata | Next.js

Use the Metadata API to define metadata in any layout or page.

nextjs.org