Nuxt 3 SEO Setup: Sitemap, Canonical, hreflang, and Structured Data (JSON-LD)
The SEO setup actually applied to a Nuxt 3 + Nuxt Content blog, in code: sitemap lastmod, canonical and noindex for filter URLs, ko/en hreflang, JSON-LD date format, and prerendering.
What this post covers
While investigating Google indexing problems on this blog (Nuxt 3 + Nuxt Content, deployed on Vercel), I fixed its SEO setup piece by piece. The investigation itself is in the previous two posts:
This post covers the setup I actually applied, in code. Versions:
| Package | Version |
|---|---|
| nuxt | 3.21 |
| @nuxt/content | 3.16 |
| @nuxtjs/sitemap | 8.6 |
1. Sitemap: add Nuxt Content posts automatically
A sitemap is a list telling Google "these URLs exist." To avoid editing it by hand for every post, generate it from Nuxt Content collections.
Add the @nuxtjs/sitemap module, then wrap collections with asSitemapCollection in content.config.ts, and that collection's posts go into /sitemap.xml.
// nuxt.config.ts
export default defineNuxtConfig({
site: { url: "https://iambilly-blog.vercel.app" }, // used for absolute URLs in the sitemap
modules: ["@nuxtjs/sitemap", "@nuxt/content"], // sitemap before 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",
// turn the frontmatter date (2026.10.14) into 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;
},
}
)
),
},
});
Module order matters too. If @nuxt/content comes first, the sitemap module warns that the integration may have issues.
Gotcha: don't use outer variables inside onUrl
The English collection uses the same onUrl, so it's tempting to write one helper and share it. But this breaks the build:
// ❌ calling a function defined outside onUrl
const toIsoDate = (raw) => String(raw).trim().replace(/\./g, "-");
asSitemapCollection(collection, {
name: "ko",
onUrl(url, entry) {
url.lastmod = toIsoDate(entry.date); // build error
},
});
The reason: the onUrl function is copied into the server bundle as source code. In the module's code, the function is turned into a string with fn.toString() and placed in a virtual server file. toIsoDate doesn't exist there, so the module checks at build time and throws an error saying outer variables and imports don't exist in the server bundle and should be moved inside the callback. That's why this blog deliberately duplicates the same code in both collections.
In this version, asSitemapCollection() prints a deprecation warning suggesting defineSitemapSchema() inside the collection schema instead. It works for now, but it's something to change when upgrading the module.
Is lastmod required?
No. A sitemap without lastmod is fine, and Google reads its URLs as-is. But Google says it uses lastmod when recrawling if it's accurate. This blog has accurate dates in frontmatter, so it includes them. Stamping today's date on every build when nothing changed would do more harm than good.
2. Canonical: the page's representative URL
A canonical tells Google "when the same content opens at several URLs, this one is the representative."
On post pages, set the current path as an absolute URL with useHead.
// pages/en/[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 for filter URLs
The list pages put filters in the URL, like /?tag=nodejs or /en?field=backend. The canonical was set to /, but in actual site: results, filter URLs were each indexed, so the same list page showed up several times. A canonical is a "hint" to Google, and Google can decide differently.
So for filtered views, robots becomes noindex, follow: don't index this, but keep following the post links in it.
// pages/en/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") },
],
});
There's a limit. This blog prerenders / and /en as static HTML, so the HTML the server sends says index, follow with or without a filter. The noindex only applies after JavaScript runs in the browser. Google renders pages, and I confirmed in Chromium that it applies, but crawlers that only read the server HTML won't see it.
3. hreflang: pair Korean and English posts
hreflang says "the other-language version of this post is at that URL." It lets Google know that the same content in two languages is a translation, not a duplicate.
I made a file listing only the translated pairs.
// constants/enPairs.js
export const EN_PAIRS = {
"javascript-event-loop": "javascript-event-loop",
"cors-guide": "cors-guide",
// Korean slug: English slug
};
export const enToKoSlug = (enSlug) => {
const entry = Object.entries(EN_PAIRS).find(([, en]) => en === enSlug);
return entry ? entry[0] : null;
};
Post pages add three alternate links only when a pair exists.
const koSlug = computed(() => enToKoSlug(route.params.slug));
const base = "https://iambilly-blog.vercel.app";
useHead(() => ({
link: koSlug.value
? [
{ rel: "alternate", hreflang: "ko", href: `${base}/ko/${koSlug.value}` },
{ rel: "alternate", hreflang: "en", href: `${base}${route.path}` },
{ rel: "alternate", hreflang: "x-default", href: `${base}/ko/${koSlug.value}` },
]
: [],
}));
| Rule | Why |
|---|---|
| Both pages must point to each other | If only one does, Google may ignore it. The Korean post page has the same three lines |
| Include the page itself | The English post lists its own URL with hreflang="en" |
| x-default is the URL to show when no language matches | This blog uses the Korean post as the default |
@nuxtjs/sitemap reads these tags from prerendered pages and adds hreflang entries to the sitemap too.
4. Structured data (JSON-LD): dates in ISO 8601
Structured data spells out, in a machine-readable way, "this page is a blog post, its title is this, it was published on this date."
Post pages include a 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" },
}),
},
],
}));
I made a mistake here. At first, the frontmatter date (2026.10.14) went into datePublished as-is. schema.org and Google expect ISO 8601 (2026-10-14, or with a time zone, 2026-10-14T00:00:00+09:00), so a dot-separated date may not be read as a date. So I added a converter.
// 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;
};
// without a time zone the meaning is ambiguous, so add Korea time (+09:00)
export const isoDateTime = (post) => {
const d = isoDate(post);
return d ? `${d}T00:00:00+09:00` : undefined;
};
WebSite and a site name on the home page
The home page has a WebSite describing the site itself, and every page has og:site_name. Google results were showing "Vercel" as the site name instead of this blog's name, so I filled in the values Google looks at when choosing a site name.
// 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/",
}),
},
],
});
Since 2023, Google supports site names for subdomains like iambilly-blog.vercel.app, and recommends name and alternateName in WebSite structured data to signal your preferred name. But Google chooses the name from several signals, so it's not guaranteed. You have to check search results after deploying.
5. Prerender: every post as static HTML
Prerendering builds pages into HTML files ahead of time. The server doesn't render on every request, so it's fast, and crawlers are less likely to get an empty page because of a server error.
// nuxt.config.ts
nitro: {
prerender: {
crawlLinks: true, // start at / and follow links
ignore: [(route: string) => route.includes("?")], // don't build filter URLs
routes: ["/en"], // add starting points links don't reach
},
},
crawlLinks only builds pages reachable through links. So this setting doubles as a link-structure check. While the "Load more" button problem existed, this crawler found only 35 pages. After adding an /archive page listing every post as a link, it built all 159 routes. If the page count in the build log is lower than your post count, Google's crawler can't reach those posts through links either.
Without ignore, it follows the list's filter links (/?tag=...) and builds dozens of copies of the same list.
6. If you moved URLs, use 301 redirects
When post URLs moved from /blog/... to /ko/..., the old URLs got 301 redirects through routeRules.
routeRules: {
"/blog": { redirect: { to: "/", statusCode: 301 } },
"/blog/**": { redirect: { to: "/ko/**", statusCode: 301 } },
},
A 301 means "moved permanently," so Google transfers the old URL's indexing to the new one.
Summary: checklist
| Item | How to check |
|---|---|
| Sitemap | Does /sitemap.xml have as many URLs as posts, matching the real post URLs? |
| Canonical | Does the page source have a canonical pointing to itself? |
| Filter URLs | After rendering a filter URL, is robots noindex, follow? |
| hreflang | Do the ko/en pages point to each other? |
| Structured data | Are dates ISO 8601? Check with Google's Rich Results Test |
| Prerender | Is the build log's page count at least the post count? |
| Link structure | Is there an <a href> to every post (an all-posts page)? |
More important than any single setting was checking the actual output after setting it up. Most of this blog's problems were "configured, but the result wasn't what I expected": the sitemap URLs that didn't match real post URLs, the date format, and the indexed filter URLs were all found only by opening the output and looking.
References
- Nuxt SEO: Sitemap - Nuxt Content integration
- Google Search Central: Build and submit a sitemap
- Google Search Central: Consolidate duplicate URLs (canonical)
- Google Search Central: Tell Google about localized versions of your page (hreflang)
- Google Search Central: Article structured data
- Google Search Central: Site names
- Google Search Central Blog: An update on site names (2023)