fe-seo

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

FE SEO & Metadata Optimization

前端SEO与元数据优化

$ARGUMENTS
를 분석하여 SEO 관련 메타데이터를 최적화하거나 생성한다.
分析
$ARGUMENTS
以优化或生成SEO相关元数据。

분석 절차

分析流程

  1. 현재 상태 파악: 프로젝트의 메타데이터 설정을 Glob/Read로 확인한다
  2. SEO 체크리스트 검사: 아래 항목에 대해 누락 사항을 확인한다
  3. 개선안 제시: 구체적인 코드와 함께 최적화 방안을 제시한다
  4. 구현: 승인 후 메타데이터를 추가/수정한다
  1. 掌握当前状态:通过Glob/Read查看项目的元数据设置
  2. 检查SEO检查清单:检查以下项目的遗漏情况
  3. 提出改进方案:结合具体代码给出优化方案
  4. 实施:获得批准后添加/修改元数据

Next.js Metadata API

Next.js Metadata API

정적 Metadata

静态Metadata

tsx
// src/app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "사이트명",
    template: "%s | 사이트명",  // 하위 페이지에서 title만 지정하면 자동 조합
  },
  description: "사이트 설명 (155자 이내 권장)",
  keywords: ["키워드1", "키워드2", "키워드3"],
  authors: [{ name: "작성자명" }],
  creator: "회사명",
  openGraph: {
    type: "website",
    locale: "ko_KR",
    url: "https://example.com",
    siteName: "사이트명",
    title: "사이트명",
    description: "사이트 설명",
    images: [
      {
        url: "/og-image.png",
        width: 1200,
        height: 630,
        alt: "사이트명 대표 이미지",
      },
    ],
  },
  twitter: {
    card: "summary_large_image",
    title: "사이트명",
    description: "사이트 설명",
    images: ["/og-image.png"],
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      "max-video-preview": -1,
      "max-image-preview": "large",
      "max-snippet": -1,
    },
  },
  verification: {
    google: "google-verification-code",
    naver: "naver-verification-code",
  },
};
tsx
// src/app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "站点名称",
    template: "%s | 站点名称",  // 子页面只需指定title即可自动组合
  },
  description: "站点描述(建议155字以内)",
  keywords: ["关键词1", "关键词2", "关键词3"],
  authors: [{ name: "作者名称" }],
  creator: "公司名称",
  openGraph: {
    type: "website",
    locale: "ko_KR",
    url: "https://example.com",
    siteName: "站点名称",
    title: "站点名称",
    description: "站点描述",
    images: [
      {
        url: "/og-image.png",
        width: 1200,
        height: 630,
        alt: "站点名称代表图片",
      },
    ],
  },
  twitter: {
    card: "summary_large_image",
    title: "站点名称",
    description: "站点描述",
    images: ["/og-image.png"],
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      "max-video-preview": -1,
      "max-image-preview": "large",
      "max-snippet": -1,
    },
  },
  verification: {
    google: "google-verification-code",
    naver: "naver-verification-code",
  },
};

동적 Metadata (페이지별)

动态Metadata(按页面)

tsx
// src/app/blog/[slug]/page.tsx
import type { Metadata } from "next";

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

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    return { title: "포스트를 찾을 수 없습니다" };
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
      authors: [post.author.name],
      images: [
        {
          url: post.coverImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

export default async function BlogPostPage({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPost(slug);
  // ...
}
tsx
// src/app/blog/[slug]/page.tsx
import type { Metadata } from "next";

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

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    return { title: "无法找到文章" };
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
      authors: [post.author.name],
      images: [
        {
          url: post.coverImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

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

JSON-LD 구조화 데이터

JSON-LD结构化数据

웹사이트 (조직)

网站(组织)

tsx
// src/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Organization",
    name: "회사명",
    url: "https://example.com",
    logo: "https://example.com/logo.png",
    sameAs: [
      "https://twitter.com/example",
      "https://github.com/example",
    ],
  };

  return (
    <html lang="ko">
      <body>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
        />
        {children}
      </body>
    </html>
  );
}
tsx
// src/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Organization",
    name: "公司名称",
    url: "https://example.com",
    logo: "https://example.com/logo.png",
    sameAs: [
      "https://twitter.com/example",
      "https://github.com/example",
    ],
  };

  return (
    <html lang="ko">
      <body>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
        />
        {children}
      </body>
    </html>
  );
}

블로그 포스트 (Article)

博客文章(Article)

tsx
// src/app/blog/[slug]/page.tsx
export default async function BlogPostPage({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPost(slug);

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    description: post.excerpt,
    image: post.coverImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      "@type": "Person",
      name: post.author.name,
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{/* ... */}</article>
    </>
  );
}
tsx
// src/app/blog/[slug]/page.tsx
export default async function BlogPostPage({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPost(slug);

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    description: post.excerpt,
    image: post.coverImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      "@type": "Person",
      name: post.author.name,
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{/* ... */}</article>
    </>
  );
}

상품 (Product)

商品(Product)

tsx
const jsonLd = {
  "@context": "https://schema.org",
  "@type": "Product",
  name: product.name,
  description: product.description,
  image: product.images,
  offers: {
    "@type": "Offer",
    price: product.price,
    priceCurrency: "KRW",
    availability: product.inStock
      ? "https://schema.org/InStock"
      : "https://schema.org/OutOfStock",
  },
  aggregateRating: {
    "@type": "AggregateRating",
    ratingValue: product.rating,
    reviewCount: product.reviewCount,
  },
};
tsx
const jsonLd = {
  "@context": "https://schema.org",
  "@type": "Product",
  name: product.name,
  description: product.description,
  image: product.images,
  offers: {
    "@type": "Offer",
    price: product.price,
    priceCurrency: "KRW",
    availability: product.inStock
      ? "https://schema.org/InStock"
      : "https://schema.org/OutOfStock",
  },
  aggregateRating: {
    "@type": "AggregateRating",
    ratingValue: product.rating,
    reviewCount: product.reviewCount,
  },
};

FAQ

FAQ

tsx
const jsonLd = {
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: faqs.map((faq) => ({
    "@type": "Question",
    name: faq.question,
    acceptedAnswer: {
      "@type": "Answer",
      text: faq.answer,
    },
  })),
};
tsx
const jsonLd = {
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: faqs.map((faq) => ({
    "@type": "Question",
    name: faq.question,
    acceptedAnswer: {
      "@type": "Answer",
      text: faq.answer,
    },
  })),
};

BreadcrumbList

BreadcrumbList(面包屑导航)

tsx
const jsonLd = {
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  itemListElement: [
    { "@type": "ListItem", position: 1, name: "홈", item: "https://example.com" },
    { "@type": "ListItem", position: 2, name: "블로그", item: "https://example.com/blog" },
    { "@type": "ListItem", position: 3, name: post.title },
  ],
};
tsx
const jsonLd = {
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  itemListElement: [
    { "@type": "ListItem", position: 1, name: "首页", item: "https://example.com" },
    { "@type": "ListItem", position: 2, name: "博客", item: "https://example.com/blog" },
    { "@type": "ListItem", position: 3, name: post.title },
  ],
};

Sitemap

Sitemap(站点地图)

정적 Sitemap

静态Sitemap

typescript
// src/app/sitemap.ts
import type { MetadataRoute } from "next";

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: "https://example.com",
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 1,
    },
    {
      url: "https://example.com/about",
      lastModified: new Date(),
      changeFrequency: "monthly",
      priority: 0.8,
    },
  ];
}
typescript
// src/app/sitemap.ts
import type { MetadataRoute } from "next";

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: "https://example.com",
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 1,
    },
    {
      url: "https://example.com/about",
      lastModified: new Date(),
      changeFrequency: "monthly",
      priority: 0.8,
    },
  ];
}

동적 Sitemap (DB에서 생성)

动态Sitemap(从数据库生成)

typescript
// src/app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await db.post.findMany({
    select: { slug: true, updatedAt: true },
  });

  const postEntries = posts.map((post) => ({
    url: `https://example.com/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: "weekly" as const,
    priority: 0.7,
  }));

  return [
    {
      url: "https://example.com",
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 1,
    },
    ...postEntries,
  ];
}
typescript
// src/app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await db.post.findMany({
    select: { slug: true, updatedAt: true },
  });

  const postEntries = posts.map((post) => ({
    url: `https://example.com/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: "weekly" as const,
    priority: 0.7,
  }));

  return [
    {
      url: "https://example.com",
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 1,
    },
    ...postEntries,
  ];
}

대규모 사이트맵 (50,000개 초과)

大规模站点地图(超过50,000条)

typescript
// src/app/sitemap/[id]/route.ts — 여러 사이트맵 파일로 분할
export async function generateSitemaps() {
  const totalProducts = await db.product.count();
  const numberOfSitemaps = Math.ceil(totalProducts / 50000);

  return Array.from({ length: numberOfSitemaps }, (_, i) => ({ id: i }));
}

export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
  const start = id * 50000;
  const products = await db.product.findMany({
    skip: start,
    take: 50000,
    select: { slug: true, updatedAt: true },
  });

  return products.map((product) => ({
    url: `https://example.com/products/${product.slug}`,
    lastModified: product.updatedAt,
  }));
}
typescript
// src/app/sitemap/[id]/route.ts — 拆分为多个站点地图文件
export async function generateSitemaps() {
  const totalProducts = await db.product.count();
  const numberOfSitemaps = Math.ceil(totalProducts / 50000);

  return Array.from({ length: numberOfSitemaps }, (_, i) => ({ id: i }));
}

export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
  const start = id * 50000;
  const products = await db.product.findMany({
    skip: start,
    take: 50000,
    select: { slug: true, updatedAt: true },
  });

  return products.map((product) => ({
    url: `https://example.com/products/${product.slug}`,
    lastModified: product.updatedAt,
  }));
}

Robots.txt

Robots.txt

typescript
// src/app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/api/", "/admin/", "/private/"],
      },
    ],
    sitemap: "https://example.com/sitemap.xml",
  };
}
typescript
// src/app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/api/", "/admin/", "/private/"],
      },
    ],
    sitemap: "https://example.com/sitemap.xml",
  };
}

동적 OG 이미지 생성

动态OG图片生成

tsx
// src/app/api/og/route.tsx
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";

export const runtime = "edge";

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const title = searchParams.get("title") ?? "Default Title";

  return new ImageResponse(
    (
      <div
        style={{
          height: "100%",
          width: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          backgroundColor: "#0a0a0a",
          color: "#fafafa",
          fontSize: 48,
          fontWeight: 700,
        }}
      >
        <div style={{ marginBottom: 24 }}>사이트명</div>
        <div style={{ fontSize: 32, color: "#a1a1aa" }}>{title}</div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}
tsx
// 페이지에서 동적 OG 이미지 연결
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    openGraph: {
      images: [`/api/og?title=${encodeURIComponent(post.title)}`],
    },
  };
}
tsx
// src/app/api/og/route.tsx
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";

export const runtime = "edge";

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const title = searchParams.get("title") ?? "Default Title";

  return new ImageResponse(
    (
      <div
        style={{
          height: "100%",
          width: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          backgroundColor: "#0a0a0a",
          color: "#fafafa",
          fontSize: 48,
          fontWeight: 700,
        }}
      >
        <div style={{ marginBottom: 24 }}>站点名称</div>
        <div style={{ fontSize: 32, color: "#a1a1aa" }}>{title}</div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}
tsx
// 页面中关联动态OG图片
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    openGraph: {
      images: [`/api/og?title=${encodeURIComponent(post.title)}`],
    },
  };
}

SEO 체크리스트

SEO检查清单

항목설명필수
<title>
페이지별 고유 타이틀 (60자 이내)O
<meta description>
페이지별 고유 설명 (155자 이내)O
<meta viewport>
width=device-width, initial-scale=1
O
<html lang>
페이지 언어 설정 (
ko
)
O
<link rel="canonical">
정규 URL 설정 (중복 방지)O
Open Graph
og:title
,
og:description
,
og:image
O
Twitter Card
twitter:card
,
twitter:title
권장
JSON-LD구조화 데이터 (페이지 유형별)권장
sitemap.xml전체 페이지 목록O
robots.txt크롤링 규칙O
시맨틱 HTML
h1
~
h6
계층,
<main>
,
<article>
O
이미지
alt
모든 의미 있는 이미지에 대체 텍스트O
HTTPSSSL 인증서 적용O
모바일 친화적반응형 디자인O
페이지 속도Core Web Vitals 충족권장
项目说明必填
<title>
每个页面的唯一标题(建议60字以内)O
<meta description>
每个页面的唯一描述(建议155字以内)O
<meta viewport>
设置
width=device-width, initial-scale=1
O
<html lang>
页面语言设置(如
ko
O
<link rel="canonical">
规范URL设置(避免重复内容)O
Open Graph配置
og:title
,
og:description
,
og:image
O
Twitter Card配置
twitter:card
,
twitter:title
推荐
JSON-LD根据页面类型配置结构化数据推荐
sitemap.xml包含所有页面的列表O
robots.txt配置爬虫规则O
语义化HTML正确使用
h1
~
h6
层级、
<main>
,
<article>
等标签
O
图片
alt
所有有意义的图片添加替代文本O
HTTPS应用SSL证书O
移动端适配响应式设计O
页面速度满足Core Web Vitals指标推荐

리포트 형식

报告格式

markdown
undefined
markdown
undefined

SEO Audit: [대상]

SEO审计: [目标对象]

요약

摘要

  • SEO 점수: [N/100]
  • 필수 항목 누락: N개
  • 권장 항목 누락: N개
  • SEO评分: [N/100]
  • 必填项遗漏: N个
  • 推荐项遗漏: N个

필수 수정

必须修改

[S1] 이슈 제목

[S1] 问题标题

  • 항목: [title / description / OG / ...]
  • 현재: 없음 또는 현재 값
  • 수정안: 코드
  • 项目: [title / description / OG / ...]
  • 当前状态: 无或当前值
  • 修改方案: 代码示例

권장 개선

推荐改进

...
...

통과 항목

通过项目

  • ...
undefined
  • ...
undefined

실행 규칙

执行规则

  1. 인자가 없으면 프로젝트 전체의 SEO 상태를 점검한다
  2. sitemap
    인자 시 sitemap.ts 파일을 생성/개선한다
  3. og-image
    인자 시 동적 OG 이미지 Route Handler를 생성한다
  4. 파일 경로가 전달되면 해당 페이지의 메타데이터를 분석한다
  5. layout.tsx
    의 전역 메타데이터와 개별 페이지 메타데이터의 상속 구조를 확인한다
  6. metadataBase
    가 설정되어 있는지 확인하고, 없으면 추가를 안내한다
  1. 无参数时,检查整个项目的SEO状态
  2. 传入
    sitemap
    参数时,生成/优化sitemap.ts文件
  3. 传入
    og-image
    参数时,创建动态OG图片Route Handler
  4. 传入文件路径时,分析该页面的元数据
  5. 检查
    layout.tsx
    的全局元数据与单个页面元数据的继承结构
  6. 确认
    metadataBase
    是否已设置,未设置则提示添加