🤖 Next.js MetadataRoute 정복: Robots, Sitemap, Manifest까지 한 번에
Next.js App Router에서는 SEO를 체계적으로 관리할 수 있도록 MetadataRoute 기능을 지원한다.
MetadataRoute는 Next.js가 제공하는 특수 파일명 기반의 라우팅 규칙으로 app/ 디렉터리 하위에 sitemap.ts, robots.ts, manifest.ts, opengraph-image.tsx 등의 파일을 두면 해당 경로로 자동 라우팅 되며 브라우저와 크롤러가 요청할 때 필요한 HTTP 헤더와 응답이 자동으로 설정된다.
또한 이 메타데이터 파일들은 기본적으로 정적 최적화가 적용되어 빌드 시 캐시 되며 필요에 따라 revalidate 옵션이나 dynamic = ‘force-dynamic’ 설정을 통해 동적 갱신 전략도 지정할 수 있다.
1) Robots
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 경로로 자동 라우팅 된다.
2) Sitemap
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을 통해 하루에 한 번 갱신되도록 캐시 전략을 적용하였다.
3) PWA Manifest
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이 없으면 홈 화면 추가 시 레이아웃이 깨질 수 있다.