fe-seo
SKILL.md
FE SEO & Metadata Optimization
$ARGUMENTS를 분석하여 SEO 관련 메타데이터를 최적화하거나 생성한다.
분석 절차
- 현재 상태 파악: 프로젝트의 메타데이터 설정을 Glob/Read로 확인한다
- SEO 체크리스트 검사: 아래 항목에 대해 누락 사항을 확인한다
- 개선안 제시: 구체적인 코드와 함께 최적화 방안을 제시한다
- 구현: 승인 후 메타데이터를 추가/수정한다
Next.js Metadata API
정적 Metadata
// 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 (페이지별)
// 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 구조화 데이터
웹사이트 (조직)
// 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)
// 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)
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
const jsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: {
"@type": "Answer",
text: faq.answer,
},
})),
};
BreadcrumbList
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
// 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에서 생성)
// 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개 초과)
// 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
// 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 이미지 생성
// 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 }
);
}
// 페이지에서 동적 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 체크리스트
| 항목 | 설명 | 필수 |
|---|---|---|
<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 충족 | 권장 |
리포트 형식
# SEO Audit: [대상]
## 요약
- SEO 점수: [N/100]
- 필수 항목 누락: N개
- 권장 항목 누락: N개
## 필수 수정
### [S1] 이슈 제목
- **항목**: [title / description / OG / ...]
- **현재**: 없음 또는 현재 값
- **수정안**: 코드
## 권장 개선
...
## 통과 항목
- ...
실행 규칙
- 인자가 없으면 프로젝트 전체의 SEO 상태를 점검한다
sitemap인자 시 sitemap.ts 파일을 생성/개선한다og-image인자 시 동적 OG 이미지 Route Handler를 생성한다- 파일 경로가 전달되면 해당 페이지의 메타데이터를 분석한다
layout.tsx의 전역 메타데이터와 개별 페이지 메타데이터의 상속 구조를 확인한다metadataBase가 설정되어 있는지 확인하고, 없으면 추가를 안내한다
Weekly Installs
3
Repository
ingpdw/pdw-fe-dev-toolFirst Seen
Feb 7, 2026
Security Audits
Installed on
cline3
gemini-cli3
github-copilot3
codex3
cursor3
opencode3