Nuxt 3 SEO 설정 정리: sitemap, canonical, hreflang, 구조화 데이터(JSON-LD)
Nuxt 3와 Nuxt Content 블로그에 실제로 적용한 SEO 설정을 코드로 정리했다. 사이트맵 lastmod, canonical과 필터 주소 noindex, 한/영 hreflang, JSON-LD 날짜 형식, prerender까지.
이 글의 범위
이 블로그(Nuxt 3 + Nuxt Content, Vercel 배포)의 구글 색인 문제를 조사하면서 SEO 설정을 하나씩 고쳤다. 원인 조사 과정은 앞의 두 글에 정리했다.
이 글은 그 결과로 실제로 적용한 설정을 코드로 정리한다. 버전은 다음과 같다.
| 패키지 | 버전 |
|---|---|
| nuxt | 3.21 |
| @nuxt/content | 3.16 |
| @nuxtjs/sitemap | 8.6 |
1. 사이트맵(sitemap): Nuxt Content 글을 자동으로 넣기
사이트맵은 구글에게 "이런 주소들이 있다"고 알려주는 목록이다. 글을 쓸 때마다 손으로 고치지 않도록, Nuxt Content 컬렉션에서 자동으로 만든다.
@nuxtjs/sitemap 모듈을 넣고, content.config.ts에서 컬렉션을 asSitemapCollection으로 감싸면 그 컬렉션의 글이 /sitemap.xml에 들어간다.
// nuxt.config.ts
export default defineNuxtConfig({
site: { url: "https://iambilly-blog.vercel.app" }, // 사이트맵의 절대 주소에 쓰인다
modules: ["@nuxtjs/sitemap", "@nuxt/content"], // sitemap을 content보다 먼저
});
// content.config.ts
import { defineContentConfig, defineCollection } from "@nuxt/content";
import { asSitemapCollection } from "@nuxtjs/sitemap/content";
export default defineContentConfig({
collections: {
ko: defineCollection(
asSitemapCollection(
{ type: "page", source: "ko/*.md" },
{
name: "ko",
// frontmatter의 date(2026.10.14)를 lastmod(2026-10-14)로 넣는다
onUrl(url, entry) {
const raw = entry.date ?? entry.meta?.date;
const iso = raw ? String(raw).trim().replace(/\./g, "-") : "";
if (/^\d{4}-\d{2}-\d{2}$/.test(iso)) url.lastmod = iso;
},
}
)
),
},
});
modules 순서도 중요하다. @nuxt/content를 먼저 넣으면 sitemap 모듈이 "연동에 문제가 생길 수 있다"는 경고를 낸다.
함정: onUrl 안에서 바깥 변수를 쓰면 안 된다
영어 컬렉션에도 똑같은 onUrl을 쓰니, 함수를 하나 만들어서 두 곳에서 같이 쓰고 싶어진다. 그런데 이렇게 하면 빌드가 실패한다.
// ❌ 바깥에 정의한 함수를 onUrl 안에서 부른다
const toIsoDate = (raw) => String(raw).trim().replace(/\./g, "-");
asSitemapCollection(collection, {
name: "ko",
onUrl(url, entry) {
url.lastmod = toIsoDate(entry.date); // 빌드 에러
},
});
이유는 onUrl 함수가 소스 코드 그대로 서버 번들에 복사되기 때문이다. 모듈 코드를 열어보면 함수를 fn.toString()으로 문자열로 바꿔서 서버 쪽 가상 파일에 넣는다. 복사된 곳에는 toIsoDate가 없으니, 모듈이 빌드할 때 미리 검사해서 "바깥 변수나 import는 서버 번들에 없으니 콜백 안으로 옮기라"는 에러를 낸다. 그래서 이 블로그는 두 컬렉션에 같은 코드를 일부러 중복해서 썼다.
이 버전에서는 asSitemapCollection()을 쓰면 "deprecated(앞으로 없어질 예정)이니 컬렉션 schema 안에서 defineSitemapSchema()를 쓰라"는 경고가 나온다. 지금은 동작하지만, 모듈을 올릴 때 바꿔야 할 부분이다.
lastmod는 필수인가?
아니다. lastmod가 없어도 사이트맵은 정상이고, 구글은 사이트맵의 주소를 그대로 읽는다. 다만 구글은 lastmod가 정확하다면 다시 크롤링할 때 참고한다고 안내한다. 그래서 날짜가 정확한 frontmatter 값이 있으니 넣은 것이다. 글을 고치지 않았는데 빌드할 때마다 오늘 날짜를 넣는 식이면 오히려 신뢰를 잃는다.
2. canonical: 이 페이지의 대표 주소
canonical은 "같은 내용이 여러 주소로 열릴 때, 이 주소가 대표"라고 구글에 알려준다.
글 페이지에서는 useHead로 현재 경로를 절대 주소로 넣는다.
// pages/ko/[slug]/index.vue
useHead(() => ({
title: `${post.value.title} | iambilly`,
link: [
{ rel: "canonical", href: `https://iambilly-blog.vercel.app${route.path}` },
],
meta: [
{ name: "description", content: post.value.description },
{ property: "og:title", content: post.value.title },
{ property: "og:url", content: `https://iambilly-blog.vercel.app${route.path}` },
],
}));
필터 주소는 noindex, follow
목록 페이지는 /?tag=nodejs, /?field=backend처럼 필터를 주소에 담는다. canonical은 /로 되어 있었지만, 실제 site: 검색 결과를 보니 필터 주소들이 각각 색인되어 같은 목록 페이지가 여러 번 나오고 있었다. canonical은 구글에게 주는 "힌트"라서, 구글이 다르게 판단할 수 있다.
그래서 필터가 붙은 보기에는 robots를 noindex, follow로 바꿨다. 색인은 하지 말되, 그 안의 글 링크는 계속 따라가라는 뜻이다.
// pages/index.vue
const route = useRoute();
const isFilteredView = computed(() => ["field", "tag", "q"].some((k) => k in route.query));
useHead({
meta: [
{ name: "robots", content: () => (isFilteredView.value ? "noindex, follow" : "index, follow") },
],
});
한계가 있다. 이 블로그는 /를 정적 HTML로 미리 만들기(prerender) 때문에, 서버가 보내는 HTML은 필터가 있든 없든 똑같이 index, follow다. noindex는 브라우저에서 자바스크립트가 실행된 뒤에 바뀐다. 구글은 페이지를 렌더링해서 보기 때문에 적용되는 것을 Chromium으로 확인했지만, 서버 HTML만 보는 크롤러에게는 적용되지 않는다.
3. hreflang: 한국어 글과 영어 글을 짝으로 연결
hreflang은 "이 글의 다른 언어 버전은 저 주소"라고 알려준다. 같은 내용을 두 언어로 쓴 글이 중복이 아니라 번역이라는 걸 구글이 알 수 있다.
번역한 글만 짝을 적어 두는 파일을 만들었다.
// constants/enPairs.js
export const EN_PAIRS = {
"javascript-event-loop": "javascript-event-loop",
"cors-guide": "cors-guide",
// 한국어 슬러그: 영어 슬러그
};
export const koToEnSlug = (koSlug) => EN_PAIRS[koSlug] ?? null;
글 페이지에서는 짝이 있을 때만 alternate 링크 세 개를 넣는다.
const enSlug = computed(() => koToEnSlug(route.params.slug));
const base = "https://iambilly-blog.vercel.app";
useHead(() => ({
link: enSlug.value
? [
{ rel: "alternate", hreflang: "ko", href: `${base}${route.path}` },
{ rel: "alternate", hreflang: "en", href: `${base}/en/${enSlug.value}` },
{ rel: "alternate", hreflang: "x-default", href: `${base}${route.path}` },
]
: [],
}));
| 규칙 | 이유 |
|---|---|
| 양쪽 모두 서로를 가리켜야 한다 | 한쪽만 가리키면 구글이 무시할 수 있다. 영어 글 페이지에도 같은 세 줄을 넣는다 |
| 자기 자신도 포함한다 | 한국어 글에도 hreflang="ko"로 자기 주소를 넣는다 |
| x-default는 어느 언어에도 맞지 않을 때 보여줄 주소 | 이 블로그는 한국어 글을 기본으로 했다 |
@nuxtjs/sitemap은 미리 만든 페이지의 이 태그를 읽어서 사이트맵에도 hreflang 항목을 넣어 준다.
4. 구조화 데이터(JSON-LD): 날짜는 ISO 8601로
구조화 데이터는 "이 페이지는 블로그 글이고, 제목은 이것, 작성일은 이것"을 기계가 읽을 수 있게 적은 것이다.
글 페이지에 BlogPosting을 넣는다.
useHead(() => ({
script: [
{
type: "application/ld+json",
innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.value.title,
description: post.value.description,
datePublished: isoDateTime(post.value), // "2026-10-14T00:00:00+09:00"
dateModified: isoDateTime(post.value),
mainEntityOfPage: `https://iambilly-blog.vercel.app${route.path}`,
author: { "@type": "Person", name: "iambilly" },
}),
},
],
}));
여기서 실수가 있었다. 처음에는 frontmatter 날짜(2026.10.14)를 그대로 datePublished에 넣고 있었다. schema.org와 구글이 요구하는 날짜 형식은 ISO 8601(2026-10-14 또는 시간대까지 붙인 2026-10-14T00:00:00+09:00)이라, 점으로 구분한 날짜는 날짜로 읽히지 않을 수 있다. 그래서 변환 함수를 만들었다.
// utils/content.js
export const isoDate = (post) => {
const m = String(post?.meta?.date ?? "").trim().match(/^(\d{4})\.(\d{2})\.(\d{2})$/);
return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
};
// 시간대가 없으면 해석이 애매해서 한국 시간(+09:00)을 붙인다
export const isoDateTime = (post) => {
const d = isoDate(post);
return d ? `${d}T00:00:00+09:00` : undefined;
};
홈에는 WebSite와 사이트 이름
홈에는 사이트 자체를 설명하는 WebSite를 넣고, 모든 페이지에는 og:site_name을 넣었다. 구글 검색 결과에 이 블로그 이름 대신 "Vercel" 이 사이트 이름으로 나오고 있어서, 구글이 사이트 이름을 정할 때 참고하는 값을 채운 것이다.
// pages/index.vue
useHead({
script: [
{
type: "application/ld+json",
innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "iambilly 기술 블로그",
alternateName: ["iambilly", "iambilly blog"],
url: "https://iambilly-blog.vercel.app/",
}),
},
],
});
// nuxt.config.ts: 모든 페이지 공통
app: {
head: {
meta: [{ property: "og:site_name", content: "iambilly 기술 블로그" }],
},
},
구글은 2023년부터 iambilly-blog.vercel.app 같은 하위 도메인에도 사이트 이름을 지원하고, 원하는 이름을 알리는 방법으로 WebSite 구조화 데이터의 name과 alternateName을 권장한다. 다만 이름은 구글이 여러 신호를 보고 정하기 때문에 반영이 보장되지는 않는다. 결과는 배포 후 검색 결과를 보고 확인해야 한다.
5. prerender: 모든 글을 정적 HTML로
prerender는 빌드할 때 페이지를 미리 HTML 파일로 만들어 두는 것이다. 요청마다 서버가 렌더링하지 않으니 빠르고, 서버 에러로 크롤러가 빈 페이지를 받을 일이 줄어든다.
// nuxt.config.ts
nitro: {
prerender: {
crawlLinks: true, // /에서 시작해 링크를 따라가며 만든다
ignore: [(route: string) => route.includes("?")], // 필터 주소는 만들지 않는다
routes: ["/en"], // 링크로 닿지 않는 시작점은 직접 추가
},
},
crawlLinks는 링크로 닿는 페이지만 만든다. 그래서 이 설정은 사이트의 링크 구조를 검사하는 도구 역할도 한다. "더 보기" 버튼 문제가 있던 때는 이 크롤러가 35개 페이지만 찾았고, 모든 글을 링크로 나열하는 /archive 페이지를 추가한 뒤에는 159개 경로를 모두 만들었다. 빌드 로그의 페이지 수가 글 수보다 적다면, 구글 크롤러도 링크로는 그 글들에 닿지 못한다는 신호다.
ignore가 없으면 목록의 필터 링크(/?tag=...)까지 따라가서 같은 목록을 수십 개 만든다.
6. 주소를 옮겼다면 301 리다이렉트
글 주소를 /blog/...에서 /ko/...로 옮기면서, 예전 주소는 routeRules로 301 리다이렉트를 걸었다.
routeRules: {
"/blog": { redirect: { to: "/", statusCode: 301 } },
"/blog/**": { redirect: { to: "/ko/**", statusCode: 301 } },
},
301은 "영구적으로 옮겼다"는 뜻이라, 구글이 예전 주소의 색인을 새 주소로 넘긴다. 자세한 과정은 Nitro wildcard 리다이렉트로 안전하게 옮기기 글에 있다.
정리: 체크리스트
| 항목 | 확인 방법 |
|---|---|
| 사이트맵 | /sitemap.xml에 글 수만큼 주소가 있고, 주소가 실제 글 주소와 같은가 |
| canonical | 페이지 소스에 자기 주소를 가리키는 canonical이 있는가 |
| 필터 주소 | 필터 주소를 렌더링한 뒤 robots가 noindex, follow인가 |
| hreflang | 한/영 페이지가 서로를 가리키는가 |
| 구조화 데이터 | 날짜가 ISO 8601인가. 구글 리치 결과 테스트로 확인 |
| prerender | 빌드 로그의 페이지 수가 글 수 이상인가 |
| 링크 구조 | 모든 글로 가는 <a href>가 있는가 (전체 글 페이지) |
설정 하나하나보다 중요한 건 설정한 뒤 실제 결과물을 확인하는 것이었다. 이 블로그의 문제들은 대부분 "설정은 했는데 결과가 예상과 달랐던" 경우였다. 사이트맵 주소가 실제 글 주소와 어긋났던 예전 버그도, 날짜 형식도, 필터 주소 색인도 모두 결과물을 열어 보고서야 찾았다.