Skip to content

Multilingual Blog SEO Overhaul: Every URL in the sitemap is redirecting

I worked with an AI agent to perform an SEO overhaul on this multilingual Next.js blog. The most critical discoveries weren’t in the code—the sitemap required two-hop redirects for every URL, which could only be detected by manually testing the links.

Reading Time
14 min read
Updated
2026.08.04

In short

For sites that use middleware for language-prefix redirection, the sitemap must output URLs with language prefixes; otherwise, every URL will result in a one-hop redirect. This issue is completely invisible in the code and can only be uncovered by running `curl` on each URL in the sitemap one by one.

Multilingual Blog SEO Overhaul

This blog is a multilingual site built with Next.js App Router and next-intl, with routes in the format /zh/blog/xxx. Everything related to SEO seems to be in place: sitemap.ts, robots.ts, and the BlogPosting structured data for each post.

That is, until I downloaded the sitemap and checked it against the actual routes.

This round of optimization was done in collaboration with an AI agent. Looking back, the agent’s value didn’t lie in “knowing which SEO tags to use”—that kind of information is just a quick search away—but rather in the fact that it diligently used curl to test every single URL in the sitemap one by one and then presented the response codes right in front of you. The two most critical issues in this article both surfaced this way—they looked perfectly normal in the code.

Cause: None of the URLs in the sitemap are accessible

The original app/sitemap.ts looked something like this:

const routes = ['', 'blog', 'projects', 'knowledge', 'about'].map((route) => ({
  url: `${siteUrl}/${route}`,
  lastModified: new Date().toISOString().split('T')[0],
}));

The output is https://cassianflorin.com/blog. It looks fine.

But the actual routes are /zh/blog and /en/blog—middleware.ts redirects paths without language prefixes to those routes. In other words, every single URL in the sitemap results in a 307 redirect.

I ran a test with curl and found that there’s more than one redirect:

curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" https://cassianflorin.com/blog

https://www.cassianflorin.com/blog

307 -> https://www.cassianflorin.com/blog

data/siteMetadata.js refers to siteUrl, which is actually https://cassianflorin.com, while the primary domain configured on Vercel is www. With these two issues overlapping, the full chain is:

https://cassianflorin.com/blog          (sitemap 里写的)
  ↓ 307  非 www → www
https://www.cassianflorin.com/blog
  ↓ 307  middleware 补语言前缀
https://www.cassianflorin.com/zh/blog   (真实页面)

Crawlers will follow the redirects, so the pages will eventually be indexed and won’t be completely overlooked. However, every time a page is crawled, the process takes an extra step, and the canonical tag also points to a URL with no actual content.

What these two issues have in common: you can’t tell just by looking at the code. The string in that line—siteUrl—isn’t the problem itself; it’s that it doesn’t match the live deployment. Since then, I’ve made it a habit—whenever there’s a deployment-related check, I run a curl command against the live environment first.

Solution 1: Output URLs with language prefixes in the sitemap

Change to a structure organized by locale, with each entry including an hreflang attribute:

function localizedEntries(path: string, options: { ... }) {
  const languages = languageAlternates(path);
  return locales.map((locale) => ({
    url: localeUrl(locale, path),
    lastModified: options.lastModified,
    changeFrequency: options.changeFrequency,
    priority: locale === defaultLocale ? options.priority : ...,
    alternates: { languages },
  }));
}

Next.js’s MetadataRoute.Sitemap supports alternates.languages and renders it as the standard xhtml:link:

<url>
<loc>https://www.cassianflorin.com/zh/blog</loc>
<xhtml:link rel="alternate" hreflang="zh-CN" href="https://www.cassianflorin.com/zh/blog" />
<xhtml:link rel="alternate" hreflang="en" href="https://www.cassianflorin.com/en/blog" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://www.cassianflorin.com/zh/blog" />
</url>

I also took the opportunity to include the previously omitted tabs and blog pagination pages. The result was 22 entries → 146 entries, all of which returned a 200 status code directly.

Here’s a detail about pagination: Page 1 and /blog have exactly the same content, so pagination starts from Page 2 to avoid duplicating the same content.

Fix #2: hreflang—What to Do When Content Isn’t Translated

This site has a bilingual interface, but the article content is in Chinese only. /en/blog/xxx and /zh/blog/xxx render the same Chinese article; only the navigation, footer, and other structural elements are in English.

At first, I thought I should add a canonical tag to the /en version pointing back to /zh. But that would be wrong: hreflang requires that each language version be self-canonical; pointing back would be equivalent to telling search engines that “the English version doesn’t count,” rendering the hreflang tag ineffective.

The correct approach is for each version to use a self-canonical tag, and then declare each other using hreflang:

/zh/blog/xxx  canonical → 自己    hreflang: zh-CN, en, x-default
/en/blog/xxx  canonical → 自己    hreflang: zh-CN, en, x-default

x-default redirects to the default language (Chinese). Search engines will automatically determine which version to display to each user and will not penalize the site for duplicate content.

Fix 3: Each Page Has Three H1 Tags

I used a script to count the rendered HTML, and each post page has 3 instances of <h1>:

SourceContent
EntryCurtain Opening AnimationCassian Florin (Present on every page)
PageTitleArticle Title (the correct one)
MDX BodyWritten by the author # 一级标题

The first issue is the most outrageous—a decorative brand animation is taking up the h1 tag on every page of the site. Simply change it to div; aria-label should be kept as is, with the visuals and animation remaining completely unchanged.

The third issue is a bit trickier. Out of 17 posts, 15 have body text that starts with # 标题 (which duplicates the title in the frontmatter). Manually editing the content is both tedious and would disrupt my future writing workflow, so I wrote a remark plugin to handle this during the build process:

export default function remarkDemoteHeadings() {
  return (tree) => {
    let hasTopLevelHeading = false;
    visit(tree, 'heading', (node) => {
      if (node.depth === 1) hasTopLevelHeading = true;
    });

    if (!hasTopLevelHeading) return;

    visit(tree, 'heading', (node) => {
      node.depth = Math.min(node.depth + 1, 6);
    });
  };
}

Lower the entire hierarchy by one level rather than simply changing the h1 to h2—this way, the relative hierarchical relationships established by the author (# → ## → ###) are fully preserved; they are simply moved down one level and nested under the h1 rendered by the layout. Articles beginning with ## are completely unaffected.

For now, the main content is written as usual # 标题, and it automatically converts to a valid single H1 document during rendering.

Make it work for future posts as well

Fixing existing issues is only half the battle. More importantly, you won’t have to worry about this for new posts.

Fully automated: canonical tags, hreflang, og:locale, structured data, sitemap entries, and RSS—all derived from the frontmatter. When publishing a post, you only need to fill out the frontmatter; everything else is handled automatically.

This can’t be done automatically; it needs to be intercepted during the build process. I wrote a script called scripts/check-seo.mjs, which is the first step in the build process:

✖ 错误(中断构建)  缺 title/summary/date、图片缺 alt、封面文件不存在、slug 冲突
⚠ 警告(仅提示)    标题过长、摘要过短或超长、标签数量异常、内链写死语言前缀

There’s a detail specific to Chinese: SERP truncation is calculated based on pixel width, and Chinese characters are roughly twice as wide as Latin characters. Therefore, you can’t simply count the number of characters:

function displayWidth(text) {
  let width = 0;
  for (const char of text) {
    width += /[⺀-￿]/.test(char) ? 2 : 1;
  }
  return width;
}

Based on this criterion, a Chinese summary exceeding about 80 characters will be truncated in search results. After testing, I found that one article’s summary had a display width of 191—it was indeed too long.

Unexpected Discovery: Yarn 3 Has Never Executed prebuild After adding check-seo to prebuild, running yarn build produces no output.

The reason is that Yarn 2+ no longer automatically runs the pre / post lifecycle scripts. However, the prebuild in this repository still contains update-tags (tag translation) and generate-obsidian-graph:

"prebuild": "node scripts/patch-contentlayer-opentelemetry.mjs && node scripts/update-tags.mjs && node scripts/generate-obsidian-graph.mjs"

In other words, these two scripts have never run during the build process, either locally or on Vercel. The reason no issues have arisen so far is that their output has been committed to the repository.

The solution is to explicitly append build. However, appending it directly triggers another issue: generate-obsidian-graph depends on OBSIDIAN_VAULT_ROOT, and this environment variable only exists on my local machine. On Vercel, it will overwrite the submitted graph with empty data.

So, first modify the script to preserve existing files when Vault is not in use:

if (!vaultRoot) {
  if (existsSync(outputPath)) {
    console.log('Obsidian graph skipped: keeping existing file.');
    return;
  }
  // 只在文件缺失时才写空图谱
}

I didn’t dare to link to it until the changes were complete.

Two Issues Discovered Along the Way

The GitHub Pages workflow fails on every push. There’s a file named pages.yml in the repository that has been triggering failure emails on every push since June—but the repository isn’t even set up for Pages. Furthermore, it uses a EXPORT=1 static export, which, if actually enabled, would completely break the middleware’s language routing. Delete it.

The tracking script is running on an empty page. The Umami script has been loading continuously on the live site, but NEXT_UMAMI_ID has never been configured. It wasn’t until I went through Pliny’s source code that I discovered the root of the problem:

var Umami = (_a) => {
  var _b = _a, { src = "https://analytics.umami.is/script.js" } = _b, ...

As long as the key umamiAnalytics exists, the script will be injected, regardless of whether an ID is present. So every visit results in a wasted CDN request, and not a single piece of data is recorded. It’s rather ironic that the SEO optimization is complete, yet there’s no tool to measure its effectiveness.

Results

MetricBefore OptimizationAfter Optimization
Sitemap Entries22146
Do sitemap URLs require redirection?All require (two-step)All return a 200 status code
hreflangNone438 annotations
og:localeFixed en_USOutput by language
Number per page <h1>31
Structured dataOnly BlogPostingAdd Person, WebSite, BreadcrumbList, CollectionPage

About Working with an Agent on This Project

After going through this process, there are a few points worth noting that go beyond simply “how to configure SEO.”

Let it explore the actual environment, rather than just reading the code. The two most critical issues mentioned earlier—siteUrl written without “www” and the sitemap lacking language prefixes—are all perfectly valid code in the repository. What really exposed them was this clumsy approach: curl -w "%{http_code} -> %{redirect_url}".

At one point, I specifically had it check the Vercel dashboard instead of just guessing based on the repository. That’s when I discovered something I hadn’t even realized myself: I had integrated the SEO validation script into build, but if Vercel overrode the Build Command, this check wouldn’t run at all. Only after confirming that the “Build Command” was empty did this solution actually work. This kind of information is only available in the console, not in the code.

I made the decisions myself. There were a few crossroads where the agent didn’t make the decision for me, but instead laid out the options and their implications: whether to use “www” or “non-www” for the main domain (changing the code vs. changing the Vercel configuration, which would have different impacts on existing indexing), and which of the similarly named landing pages on the product site and the blog should be marked as canonical. These are trade-offs, not matters of right or wrong; the agent provided the context, and I made the final call—the process was handled just right.

Turn conclusions into build constraints. This is the step I found most worthwhile. check-seo.mjs I turned scattered best practices—like “don’t make summaries too long” and “include alt text for images”—into rules that cause the build to fail. Six months from now, I’ll surely have forgotten how many Chinese characters a summary should be limited to, but the build will remember.

While writing this post, I ran into a rather interesting catch-22: In the first draft, summary was set to a display width of 198, but it was flagged by a script written during the same round of updates; I had to change it to 132 to get it to pass. When I tagged the article with Next.js, it immediately crashed the build—next-intl uses . to indicate nesting, tags.Next.js gets parsed as three levels deep, and during initialization, it validates the entire messages object; as long as a key with a dot exists, it throws INVALID_KEY, regardless of whether it’s actually used. This pitfall had been lurking in the default translation table for update-tags.mjs for a long time (on the 'Next.js': 'Next.js' line), but no one had ever used that tag before.

If You’re Also Working on SEO for a Multilingual Site

Based on the order in which I ran into these issues, it’s worth checking the following first:

  1. Use curl to test each URL in the sitemap one by one to see if the response is a 200 or a 3xx. This is the most easily overlooked issue, yet it has the greatest impact.
  2. Verify that siteUrl matches the primary domain in production (www vs. non-www). This isn’t visible in the code; you’ll need to check the live site.
  3. Ensure that hreflang and canonical tags don’t conflict: Each language version must use a self-canonical tag; otherwise, the hreflang tag is invalid.
  4. Count the number of h1 tags in the rendered HTML, paying attention to those hidden within global components (navigation, animations, logo).
  5. If using Yarn 2+, check whether the pre and post scripts are running at all.

Looking back, the most valuable part of Agent’s work during this round wasn’t writing code, but tirelessly making a request for every single URL and compiling the response codes into a table. It was still up to me to figure out what was wrong and how to make trade-offs—but without that tedious comparison table, I’d probably still be under the impression that the sitemap was working properly.

Keep reading