SHIN
Next.js App Router로 풀스택 블로그 만들기
9편// 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] : [],
},
}
}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'),
}}
/>
{/* ... 나머지 페이지 */}
</>
)// 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,
]
}// 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' },
})
}// 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 배포와 성능 최적화를 다룹니다.