SHIN STORYSHIN STORY
홈포스트C#TypeScriptNext.jsNode.js시리즈
</>SHIN STORY

sdf

탐색

  • 홈
  • 모든 포스트
  • 시리즈
  • 검색

카테고리

  • C#
  • TypeScript
  • Next.js
  • Node.js
  • 알고리즘
  • 개발 도구

© 2025 Shin Blog. All rights reserved.

GitHubRSS
목록으로
Next.js#TypeScript#Next.js#SEO

8편. SEO 최적화 — 메타태그·sitemap·RSS

SHIN

2025년 4월 5일2분 읽기2
📚

Next.js App Router로 풀스택 블로그 만들기

9편
  1. 11편. 프로젝트 소개와 기술 스택 선정
  2. 22편. 프로젝트 초기 설정과 디렉토리 구조
  3. 33편. Prisma 스키마 설계와 마이그레이션
  4. 44편. NextAuth.js로 관리자 인증 구현하기
  5. 55편. 관리자 대시보드 — 포스트 CRUD 구현
  6. 66편. 블로그 프론트엔드와 마크다운 렌더링
  7. 77편. 검색·카테고리·태그·시리즈 기능 구현
  8. 8편. SEO 최적화 — 메타태그·sitemap·RSS현재
  9. 99편. Vercel 배포와 성능 최적화

8편. SEO 최적화 — 메타태그·sitemap·RSS

generateMetadata API

CODE
// src/app/(blog)/posts/[slug]/page.tsx
import type { Metadata } from 'next'

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const [post, settings] = await Promise.all([getPost(slug), getSettings()])
  if (!post) return {}

  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
  const canonical = `${siteUrl}/posts/${post.slug}`

  return {
    title: `${post.title} | ${settings.blogTitle}`,
    description: post.excerpt ?? settings.blogDescription,
    alternates: { canonical },
    openGraph: {
      title: post.title,
      description: post.excerpt ?? '',
      url: canonical,
      type: 'article',
      publishedTime: post.publishedAt?.toISOString(),
      authors: [post.author.name ?? ''],
      images: post.coverImage ? [post.coverImage] : [],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt ?? '',
      images: post.coverImage ? [post.coverImage] : [],
    },
  }
}

JSON-LD 구조화 데이터

CODE
const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.title,
  description: post.excerpt ?? '',
  url: `${siteUrl}/posts/${post.slug}`,
  datePublished: post.publishedAt?.toISOString(),
  dateModified: post.updatedAt.toISOString(),
  author: {
    '@type': 'Person',
    name: post.author.name ?? '',
    url: post.author.github ?? undefined,
  },
  image: post.coverImage ?? undefined,
  articleSection: post.category?.name,
  keywords: post.tags.map(({ tag }) => tag.name).join(', '),
}

return (
  <>
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(jsonLd)
          .replace(/</g, '\\u003c')
          .replace(/>/g, '\\u003e'),
      }}
    />
    {/* ... 나머지 페이지 */}
  </>
)

sitemap.xml 자동 생성

CODE
// src/app/sitemap.ts
import type { MetadataRoute } from 'next'
import { prisma } from '@/lib/prisma'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'

  const posts = await prisma.post.findMany({
    where: { status: 'PUBLISHED' },
    select: { slug: true, updatedAt: true },
    orderBy: { publishedAt: 'desc' },
  })

  const postUrls = posts.map(post => ({
    url: `${siteUrl}/posts/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }))

  return [
    { url: siteUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
    { url: `${siteUrl}/posts`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
    ...postUrls,
  ]
}

RSS 피드

CODE
// src/app/rss.xml/route.ts
import { prisma } from '@/lib/prisma'
import { getSettings } from '@/lib/settings'

export async function GET() {
  const [settings, posts] = await Promise.all([
    getSettings(),
    prisma.post.findMany({
      where: { status: 'PUBLISHED' },
      orderBy: { publishedAt: 'desc' },
      take: 20,
      select: { title: true, slug: true, excerpt: true, publishedAt: true, createdAt: true },
    }),
  ])

  const siteUrl = settings.blogUrl

  const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>${settings.blogTitle}</title>
    <link>${siteUrl}</link>
    <description>${settings.blogDescription}</description>
    <language>ko</language>
    <atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
    ${posts.map(post => `
    <item>
      <title><![CDATA[${post.title}]]></title>
      <link>${siteUrl}/posts/${post.slug}</link>
      <guid>${siteUrl}/posts/${post.slug}</guid>
      <pubDate>${(post.publishedAt ?? post.createdAt).toUTCString()}</pubDate>
      <description><![CDATA[${post.excerpt ?? ''}]]></description>
    </item>`).join('')}
  </channel>
</rss>`

  return new Response(rss, {
    headers: { 'Content-Type': 'application/xml; charset=utf-8' },
  })
}

robots.txt

CODE
// src/app/robots.ts
export default function robots() {
  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: '/admin/' },
    ],
    sitemap: `${siteUrl}/sitemap.xml`,
  }
}

다음 편에서는 Vercel 배포와 성능 최적화를 다룹니다.

공유
S

SHIN

.NET 개발자입니다

GitHub

이전 포스트

7편. 검색·카테고리·태그·시리즈 기능 구현

다음 포스트

9편. Vercel 배포와 성능 최적화

같은 카테고리 포스트

9편. Vercel 배포와 성능 최적화

2025년 4월 10일· 7분

7편. 검색·카테고리·태그·시리즈 기능 구현

2025년 3월 30일· 8분

6편. 블로그 프론트엔드와 마크다운 렌더링

2025년 3월 25일· 10분

댓글