Next.js App Router์์๋ SEO๋ฅผ ์ฒด๊ณ์ ์ผ๋ก ๊ด๋ฆฌํ ์ ์๋๋ก MetadataRoute ๊ธฐ๋ฅ์ ์ง์ํ๋ค.
MetadataRoute๋ Next.js๊ฐ ์ ๊ณตํ๋ ํน์ ํ์ผ๋ช
๊ธฐ๋ฐ์ ๋ผ์ฐํ
๊ท์น์ผ๋ก app/ ๋๋ ํฐ๋ฆฌ ํ์์ sitemap.ts, robots.ts, manifest.ts, opengraph-image.tsx ๋ฑ์ ํ์ผ์ ๋๋ฉด ํด๋น ๊ฒฝ๋ก๋ก ์๋ ๋ผ์ฐํ
๋๋ฉฐ ๋ธ๋ผ์ฐ์ ์ ํฌ๋กค๋ฌ๊ฐ ์์ฒญํ ๋ ํ์ํ HTTP ํค๋์ ์๋ต์ด ์๋์ผ๋ก ์ค์ ๋๋ค.
๋ํ ์ด ๋ฉํ๋ฐ์ดํฐ ํ์ผ๋ค์ ๊ธฐ๋ณธ์ ์ผ๋ก ์ ์ ์ต์ ํ๊ฐ ์ ์ฉ๋์ด ๋น๋ ์ ์บ์ ๋๋ฉฐ ํ์์ ๋ฐ๋ผ revalidate ์ต์
์ด๋ dynamic = โforce-dynamicโ ์ค์ ์ ํตํด ๋์ ๊ฐฑ์ ์ ๋ต๋ ์ง์ ํ ์ ์๋ค.
app/robots.ts ํ์ผ์ ํตํด ๊ฒ์ ์์ง ํฌ๋กค๋ฌ์ ๋ํ ์ ๊ทผ ์ ์ฑ
์ ์ ์ธํ ์ ์๋ค. ์ง์ robots.txt ์ ์ ํ์ผ์ ๋ง๋ค์ง ์์๋ ์๋์ฒ๋ผ ๊ฐ๋จํ ์ฝ๋๋ง์ผ๋ก ์๋์ผ๋ก /robots.txt ๊ฒฝ๋ก์ ์๋ต์ด ์์ฑ๋๋ค.
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/' }],
sitemap: 'https://pyomin.com/sitemap.xml'
}
}
ํจ์ ์ด๋ฆ์ ์์ ๋กญ๊ฒ ์ง์ ๊ฐ๋ฅํ๋ฉฐ ํ์ผ๋ช
์ ๋ฐ๋์ robots.ts ์ฌ์ผ /robots.txt ๊ฒฝ๋ก๋ก ์๋ ๋ผ์ฐํ
๋๋ค.
sitemap ์ญ์ app/sitemap.ts ํ์ผ์ ๋ง๋ค๋ฉด ์ ์ XML ํ์ผ ์์ด๋ /sitemap.xml ๊ฒฝ๋ก๊ฐ ์๋์ผ๋ก ์์ฑ๋๋ค. ๋ํ Next.js์์๋ ๋ฐ์ดํฐ ๊ธฐ๋ฐ์ sitemap๋ ์ฝ๋๋ก ๋์ ์ผ๋ก ๊ตฌ์ฑํ ์ ์๊ธฐ ๋๋ฌธ์ ๊ธ/์นดํ
๊ณ ๋ฆฌ ๋ฑ ์ฝํ
์ธ ๊ฐ ๋ง์ ์ฌ์ดํธ์์ ๋งค์ฐ ์ ์ฉํ๋ค.
import type { MetadataRoute } from "next";
import { getApiBase } from "@/lib/api/apiBases";
export const revalidate = 86400;
const SITE_URL = 'https://pyomin.com';
const staticPagesInfo = [
{ path: '/', changeFrequency: 'weekly', priority: 1.0, lastModified: undefined },
{ path: '/posts', changeFrequency: 'weekly', priority: 0.7, lastModified: undefined },
{ path: '/about', changeFrequency: 'yearly', priority: 0.5, lastModified: '2025-09-05T00:00:00.000Z' },
{ path: '/guestbooks', changeFrequency: 'yearly', priority: 0.5, lastModified: '2025-07-01T00:00:00.000Z' },
] as const;
type Sitemap = { key: string; lastModified: string };
type SitemapResponse<T> = { sitemap: T[] };
async function fetchPostSitemap(): Promise<Sitemap[]> {
const res = await fetch(`${getApiBase()}/posts/sitemap`);
if (!res.ok) throw new Error('posts/sitemap ์คํจ');
const json = (await res.json()) as SitemapResponse<Sitemap>;
return json.sitemap ?? [];
}
async function fetchCategorySitemap(): Promise<Sitemap[]> {
const res = await fetch(`${getApiBase()}/categories/sitemap`);
if (!res.ok) return [];
const json = (await res.json()) as SitemapResponse<Sitemap>;
return json.sitemap ?? [];
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [posts, categories] = await Promise.all([fetchPostSitemap(), fetchCategorySitemap()]);
const latestPostDateISO = posts.at(0)?.lastModified ?? new Date().toISOString();
const staticPages: MetadataRoute.Sitemap = staticPagesInfo.map(({ path, lastModified, changeFrequency, priority }) => ({
url: `${SITE_URL}${path}`,
lastModified: lastModified ?? latestPostDateISO,
changeFrequency,
priority
}));
const categoryPages: MetadataRoute.Sitemap = categories.map((category) => ({
url: `${SITE_URL}/posts/category/${encodeURIComponent(category.key)}`,
lastModified: category.lastModified,
changeFrequency: 'monthly',
priority: 0.6,
}));
const postPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${SITE_URL}/posts/${encodeURIComponent(post.key)}`,
lastModified: post.lastModified,
changeFrequency: 'monthly',
priority: 0.8,
}));
return [...staticPages, ...categoryPages, ...postPages];
}
์ ์ URL์ ๊ณ ์ ๋ ๋ ์ง ํน์ ์ต์ ๊ธ ๊ธฐ์ค์ผ๋ก lastModified๋ฅผ ์ค์ ํ์์ผ๋ฉฐ ์นดํ
๊ณ ๋ฆฌ/๊ธ ๋ชฉ๋ก์ API๋ฅผ ํตํด ์ค์๊ฐ fetch ํ์ฌ URL ๋ฐ ์์ ์ผ์ ์ง์ ํ์๋ค.
๋ํ export const revalidate = 86400์ ํตํด ํ๋ฃจ์ ํ ๋ฒ ๊ฐฑ์ ๋๋๋ก ์บ์ ์ ๋ต์ ์ ์ฉํ์๋ค.
app/manifest.ts ํ์ผ์ ๋ธ๋ผ์ฐ์ ์ PWA(Progressive Web App) ๊ด๋ จ ์ ๋ณด๋ฅผ ์ ๊ณตํ๋ค. ์ด ํ์ผ์ ์๋์ผ๋ก /manifest.webmanifest ๊ฒฝ๋ก๋ก ๋ผ์ฐํ
๋๋ฉฐ ์์ด์ฝ, ์ด๋ฆ, ํ
๋ง ์์, ์์ URL, ๋ฐ๋ก ๊ฐ๊ธฐ ๋ฑ ์น ์ฑ ์ค์น์ ๊ด๋ จ๋ ๋ฉํ๋ฐ์ดํฐ๋ฅผ ๋ด์ ์ ์๋ค.
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
// ์ฑ์ ์ ์ฒด ์ด๋ฆ
name: 'BlueCool Blog',
// ์ฑ์ ์งง์ ์ด๋ฆ
short_name: 'BlueCool',
// ์ฑ ์ค๋ช
description: 'BlueCool ๋ธ๋ก๊ทธ์ ๊ธ๋ค์ ๋น ๋ฅด๊ฒ ํ์ํด๋ณด์ธ์!',
// ์ฑ ๊ณ ์ ์๋ณ์
id: '/',
// ์ฑ์ด ์์๋๋ ๊ธฐ๋ณธ URL
start_url: '/',
// ์ฑ์ด ์ ๊ทผ ๊ฐ๋ฅํ URL ๋ฒ์
scope: '/',
// ์ฑ ์คํ ๋ฐฉ์
display: 'standalone',
// display๊ฐ ์ง์๋์ง ์์ ๊ฒฝ์ฐ ์ฐ์ ์์ ๋์
display_override: ['standalone', 'browser'],
// ์ฑ ๋ก๋ฉ ์ ๋ฐฐ๊ฒฝ ์
background_color: '#E6F7FB',
// ๋ธ๋ผ์ฐ์ ํด๋ฐ ๋ฐ UI์ ํ
๋ง ์์
theme_color: '#145B6F',
// ํ๋ฉด ๋ฐฉํฅ
orientation: 'any',
// ํ
์คํธ ๋ฐฉํฅ
dir: 'ltr',
// ๊ธฐ๋ณธ ์ธ์ด ์ค์
lang: 'ko-KR',
// ์ฑ์ ์นดํ
๊ณ ๋ฆฌ
categories: ['programming', 'blog', 'technology', 'education'],
// ํํ๋ฉด ์์ด์ฝ๋ค
icons: [
{ src: '/images/manifest/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/images/manifest/icon-192-maskable.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: '/images/manifest/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/images/manifest/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: '/images/manifest/icon-256.png', sizes: '256x256', type: 'image/png' },
{ src: '/images/manifest/icon-384.png', sizes: '384x384', type: 'image/png' },
{ src: '/images/manifest/icon-32.png', sizes: '32x32', type: 'image/png' },
{ src: '/images/manifest/icon-16.png', sizes: '16x16', type: 'image/png' },
],
// ์ฑ ์์ด์ฝ์ ๊ธธ๊ฒ ๋๋ ์ ๋ ๋์ค๋ ๋จ์ถ ๋ฉ๋ด
shortcuts: [
{ name: 'Posts', url: '/posts', description: '์ ์ฒด ๊ธ ๋ชฉ๋ก' },
{ name: 'About', url: '/about', description: '์๊ฐ' },
{ name: 'Guestbook', url: '/guestbooks', description: '๋ฐฉ๋ช
๋ก' },
],
};
}
manifest ์ค์ ์์ ์ฃผ์ํ ์ ์ ์์ด์ฝ์ ๋ฐ๋์ ๋ค์ํ ํด์๋(192, 512, 256 ๋ฑ)๋ก ์ ๊ณตํด์ผ ์น ์ฑ ์ค์น ๋ฒํผ์ด ์ ์์ ์ผ๋ก ๋
ธ์ถ๋๋ค. ํนํ Android๋ ์ผ๋ถ ํ๋ซํผ์์๋ maskable icon์ด ์์ผ๋ฉด ํ ํ๋ฉด ์ถ๊ฐ ์ ๋ ์ด์์์ด ๊นจ์ง ์ ์๋ค.
