현재 블로그 서비스를 Next.js를 통해 개발하면서 SEO 향상을 위해 사이트맵을 생성하였다.

초기에는 public 폴더 내부에 sitemap.xml 을 직접 생성하여 /, /home, /blog, /about 네 개의 경로를 사이트맵에 등록해두었다.

기존 sitemap.xml

// public/sitemap.xml

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" 
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
        xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 
        http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
  <url>
    <loc>https://devhailey.com/</loc>
    <lastmod>2025-01-15T08:17:59+00:00</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://devhailey.com/home</loc>
    <lastmod>2025-01-15T08:17:59+00:00</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://devhailey.com/blog</loc>
    <lastmod>2025-01-15T08:17:59+00:00</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://devhailey.com/about</loc>
    <lastmod>2025-01-15T08:17:59+00:00</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
</urlset>

사이트맵에 등록해둔 네 경로는 검색엔진이 크롤링 하는 것을 볼 수 있었다.
문제는 blog 페이지에 글이 등록되면서 발생했다.

블로그에 글을 포스팅 하면 /posts/{postID} 경로에 페이지가 생성된다.
이때 포스트 페이지를 사이트맵에 등록하지 않게되면 내 포스트 글을 검색했을 때 제대로 노출되지 않을 수 있다.

그래서 블로그 내부의 포스트들을 전부 긁어와 주기적으로 사이트맵에 반영할 수 있도록 사이트맵을 수정하였다.

App Router의 sitemap.ts

App router 부터 sitemap.ts(js)를 통해 자동으로 사이트맵을 생성해주기 때문에 간단하게 사이트맵을 생성할 수 있었다.

public 내에 있는 sitemap.xml 파일을 삭제하고 app 폴더 내부에 sitemap.ts를 생성해주었다.

기본적으로 사이트맵은 아래와 같이 작성한다.

// app/sitemap.ts

import type { MetadataRoute } from 'next'

export default function sitemap(): MetadataRoute.Sitemap {
  const baseUrl = "https://devhailey.com";
  return [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: "daily" as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/home`,
      lastModified: new Date(),
      changeFrequency: "daily" as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: new Date(),
      changeFrequency: "daily" as const,
      priority: 0.8,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: "weekly" as const,
      priority: 0.5,
    },
  ]
}

이제 각 포스트 url도 추가해보자
모든 포스트 데이터를 db에서 가져오는 함수를 실행하고 해당 데이터를 기반으로 url 배열을 변수에 담아줬다.

// app/sitemap.ts

// 비동기 함수로 변경
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = "https://devhailey.com";

  // 모든 포스트 데이터 가져오기
  const response = await getPostAll();
  const posts = (await response.json()) as Post[];

  // 블로그 포스트 URL 생성
  const postsUrls = posts.map((post) => ({
    url: `${baseUrl}/posts/${post._id}`,
    lastModified: new Date(post.createdAt),
    changeFrequency: "monthly" as const,
    priority: 0.7,
  }));

  // 정적 페이지 URL
  const staticUrls = [ 
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: "daily" as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/home`,
      lastModified: new Date(),
      changeFrequency: "daily" as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: new Date(),
      changeFrequency: "daily" as const,
      priority: 0.8,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: "weekly" as const,
      priority: 0.5,
    },
  ];

  return [...staticUrls, ...postsUrls];
}

이전에 만들어둔 정적 페이지 URL들도 따로 변수에 담아준 뒤 spread 연산자를 통해 return 해주었다.

이제 https://youresiteurl.com/sitemap.xml 으로 접속해보면 포스트 페이지들도 들어간 것을 볼 수 있다.
그런데 현재 생성한 사이트맵은 초기 빌드시에만 생성되기 때문에 주기적으로 데이터를 업데이트 해주어야한다.

최신 버전(14.2.3 ^)에서는 사이트맵 재생성을 공식적으로 지원해주기 때문에 손쉽게 ISR을 적용시킬 수 있다.

아래는 재검증 요청이 포함된 전체 코드이다.

// app/sitemap.ts
import { MetadataRoute } from "next";
import { getPostAll } from "@api/posts";
import { Post } from "@type/post";

export const revalidate = 3600; // 1시간마다 재생성

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = "https://devhailey.com";

  // 모든 포스트 데이터 가져오기
  const response = await getPostAll();
  const posts = (await response.json()) as Post[];

  // 블로그 포스트 URL 생성
  const postsUrls = posts.map((post) => ({
    url: `${baseUrl}/posts/${post._id}`,
    lastModified: new Date(post.createdAt),
    changeFrequency: "monthly" as const,
    priority: 0.7,
  }));

  // 정적 페이지 URL
  const staticUrls = [ 
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: "daily" as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/home`,
      lastModified: new Date(),
      changeFrequency: "daily" as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: new Date(),
      changeFrequency: "daily" as const,
      priority: 0.8,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: "weekly" as const,
      priority: 0.5,
    },
  ];

  return [...staticUrls, ...postsUrls];
}