Fix SEO indexing: hreflang, canonicals, sitemap, trailing slash (#2193)

* Fix SEO indexing: add hreflang, canonicals, sitemap per-locale entries

Google Search Console showed 380 not-indexed vs 86 indexed pages.
Root causes: missing hreflang tags on rendered pages (only in sitemap),
no canonical on homepage, inconsistent canonicals wiping parent hreflang,
sitemap only listing English URLs, trailing slash duplicates, and
_next/static chunks being crawled as pages.

Changes:
- Add buildAlternates() utility for consistent canonical + hreflang
- Add hreflang tags to all pages via alternates.languages in metadata
- Add self-referencing canonical URLs to every page (homepage had none)
- Expand sitemap to emit separate entries for each locale
- Add missing /docs/custom-commands to sitemap
- Remove skipTrailingSlashRedirect to normalize trailing slashes
- Block /_next/ in robots.txt to stop chunk crawling

* Add per-page alternates to docs sub-pages and blog index

Docs sub-pages and blog index only returned title/description in
generateMetadata, so they inherited the parent layout's alternates
(pointing to /docs or /blog). Now each page sets its own
buildAlternates() with the correct path so canonical and hreflang
point to the actual page URL.

* Derive openGraph.url from buildAlternates to avoid drift

* Redirect non-English legal pages to English, remove from sitemap

Legal pages (privacy policy, TOS, EULA) are untranslated English content.
Serving them under every locale creates 54 duplicate URLs. Now:
- Middleware 301-redirects /ja/privacy-policy etc. to /privacy-policy
- Sitemap only includes English URLs for legal pages (no locale variants)
- Legal page metadata uses static English-only canonical

* Fix legal page redirect to only match /<locale>/<page> paths

endsWith matched too broadly (e.g. /docs/eula). Now only redirects
when the path after the first segment is an exact legal page match.

* Skip next-intl for legal pages to prevent locale redirect loop

Without this, a Japanese user hitting /privacy-policy could be
redirected by next-intl to /ja/privacy-policy, which our middleware
redirects back to /privacy-policy, creating a loop.

* Rewrite legal pages to /en/ instead of NextResponse.next()

Pages live under app/[locale]/, so skipping next-intl entirely
would break route resolution. Rewrite to /en/privacy-policy etc.
so Next.js can resolve the [locale] segment correctly.

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-26 15:10:39 -07:00 committed by GitHub
parent f507fd8141
commit 46589f531c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 113 additions and 36 deletions

View file

@ -3,7 +3,7 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "EULA — cmux",
description: "End-User License Agreement for cmux",
alternates: { canonical: "./" },
alternates: { canonical: "https://cmux.com/eula" },
};
export default function EulaPage() {

View file

@ -4,7 +4,7 @@ import { Link } from "../../../../i18n/navigation";
export const metadata: Metadata = {
title: "Privacy Policy — cmux",
description: "Privacy policy for cmux",
alternates: { canonical: "./" },
alternates: { canonical: "https://cmux.com/privacy-policy" },
};
export default function PrivacyPolicyPage() {

View file

@ -3,7 +3,7 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Terms of Service — cmux",
description: "Terms of service for cmux",
alternates: { canonical: "./" },
alternates: { canonical: "https://cmux.com/terms-of-service" },
};
export default function TermsOfServicePage() {

View file

@ -1,11 +1,11 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { Link } from "../../../../i18n/navigation";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "blog.cmdShiftU" });
const url = locale === "en" ? "/blog/cmd-shift-u" : `/${locale}/blog/cmd-shift-u`;
return {
title: t("metaTitle"),
description: t("metaDescription"),
@ -18,14 +18,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
description: t("metaDescription"),
type: "article",
publishedTime: "2026-03-04T00:00:00Z",
url,
},
twitter: {
card: "summary_large_image",
title: t("metaTitle"),
description: t("metaDescription"),
},
alternates: { canonical: url },
alternates: buildAlternates(locale, "/blog/cmd-shift-u"),
};
}

View file

@ -1,11 +1,11 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { Link } from "../../../../i18n/navigation";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "blog.introducingCmux" });
const url = locale === "en" ? "/blog/introducing-cmux" : `/${locale}/blog/introducing-cmux`;
return {
title: t("metaTitle"),
description: t("metaDescription"),
@ -18,14 +18,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
description: t("metaDescription"),
type: "article",
publishedTime: "2026-02-12T00:00:00Z",
url,
},
twitter: {
card: "summary_large_image",
title: t("metaTitle"),
description: t("metaDescription"),
},
alternates: { canonical: url },
alternates: buildAlternates(locale, "/blog/introducing-cmux"),
};
}

View file

@ -1,4 +1,5 @@
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../i18n/seo";
import { SiteHeader } from "../components/site-header";
import { BlogPager } from "../components/blog-pager";
import { BlogCTA } from "../components/blog-cta";
@ -19,9 +20,7 @@ export async function generateMetadata({
siteName: "cmux",
type: "article" as const,
},
alternates: {
canonical: "./",
},
alternates: buildAlternates(locale, "/blog"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../i18n/seo";
import { Link } from "../../../i18n/navigation";
export async function generateMetadata({
@ -12,6 +13,7 @@ export async function generateMetadata({
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/blog"),
};
}

View file

@ -1,6 +1,7 @@
import Image from "next/image";
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { Link } from "../../../../i18n/navigation";
import { Tweet } from "react-tweet";
import starHistory from "./star-history.png";
@ -8,7 +9,6 @@ import starHistory from "./star-history.png";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "blog.showHnLaunch" });
const url = locale === "en" ? "/blog/show-hn-launch" : `/${locale}/blog/show-hn-launch`;
return {
title: t("metaTitle"),
description: t("metaDescription"),
@ -22,14 +22,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
description: t("metaDescription"),
type: "article",
publishedTime: "2026-02-21T00:00:00Z",
url,
},
twitter: {
card: "summary_large_image",
title: t("metaTitle"),
description: t("metaDescription"),
},
alternates: { canonical: url },
alternates: buildAlternates(locale, "/blog/show-hn-launch"),
};
}

View file

@ -1,11 +1,11 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { Link } from "../../../../i18n/navigation";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "blog.zenOfCmux" });
const url = locale === "en" ? "/blog/zen-of-cmux" : `/${locale}/blog/zen-of-cmux`;
return {
title: t("metaTitle"),
description: t("metaDescription"),
@ -18,14 +18,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
description: t("metaDescription"),
type: "article",
publishedTime: "2026-02-27T00:00:00Z",
url,
},
twitter: {
card: "summary_large_image",
title: t("metaTitle"),
description: t("metaDescription"),
},
alternates: { canonical: url },
alternates: buildAlternates(locale, "/blog/zen-of-cmux"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../i18n/seo";
import { SiteHeader } from "../components/site-header";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
@ -8,7 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: { canonical: "./" },
alternates: buildAlternates(locale, "/community"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { CodeBlock } from "../../components/code-block";
import { Callout } from "../../components/callout";
@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/docs/api"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { CodeBlock } from "../../components/code-block";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
@ -8,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/docs/browser-automation"),
};
}

View file

@ -3,6 +3,7 @@ import path from "path";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { changelogMedia, type VersionMedia } from "./changelog-media";
/** Read PNG dimensions from the IHDR chunk (bytes 16-23). */
@ -21,6 +22,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/docs/changelog"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { CodeBlock } from "../../components/code-block";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
@ -8,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/docs/concepts"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { CodeBlock } from "../../components/code-block";
import { Callout } from "../../components/callout";
@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/docs/configuration"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { CodeBlock } from "../../components/code-block";
import { Callout } from "../../components/callout";
@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/docs/custom-commands"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { CodeBlock } from "../../components/code-block";
import { Callout } from "../../components/callout";
import { DownloadButton } from "../../components/download-button";
@ -10,6 +11,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/docs/getting-started"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { KeyboardShortcuts } from "../../keyboard-shortcuts";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
@ -8,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/docs/keyboard-shortcuts"),
};
}

View file

@ -1,4 +1,5 @@
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../i18n/seo";
import { DocsNav } from "./docs-nav";
import { SiteHeader } from "../components/site-header";
@ -18,9 +19,7 @@ export async function generateMetadata({
siteName: "cmux",
type: "article" as const,
},
alternates: {
canonical: "./",
},
alternates: buildAlternates(locale, "/docs"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../../i18n/seo";
import { CodeBlock } from "../../components/code-block";
import { Callout } from "../../components/callout";
@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: buildAlternates(locale, "/docs/notifications"),
};
}

View file

@ -8,6 +8,7 @@ import {
} from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "../../i18n/routing";
import { buildAlternates } from "../../i18n/seo";
import { Providers } from "./providers";
import { DevPanel } from "./components/spacing-control";
import { SiteFooter } from "./components/site-footer";
@ -30,8 +31,7 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
const url =
locale === "en" ? "https://cmux.com" : `https://cmux.com/${locale}`;
const alternates = buildAlternates(locale, "");
return {
title: t("title"),
description: t("description"),
@ -52,7 +52,7 @@ export async function generateMetadata({
openGraph: {
title: t("title"),
description: t("ogDescription"),
url,
url: alternates.canonical,
siteName: "cmux",
type: "website",
},
@ -61,6 +61,7 @@ export async function generateMetadata({
title: t("title"),
description: t("ogDescription"),
},
alternates,
metadataBase: new URL("https://cmux.com"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../i18n/seo";
import { SiteHeader } from "../components/site-header";
export async function generateMetadata({
@ -12,7 +13,7 @@ export async function generateMetadata({
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: { canonical: "./" },
alternates: buildAlternates(locale, "/nightly"),
};
}

View file

@ -1,5 +1,6 @@
import { useTranslations, useLocale } from "next-intl";
import { getTranslations } from "next-intl/server";
import { buildAlternates } from "../../../i18n/seo";
import { SiteHeader } from "../components/site-header";
import { testimonials, TestimonialCard, getTestimonialTranslation } from "../testimonials";
@ -9,7 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: { canonical: "./" },
alternates: buildAlternates(locale, "/wall-of-love"),
};
}

View file

@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
rules: { userAgent: "*", allow: "/", disallow: "/_next/" },
sitemap: "https://cmux.com/sitemap.xml",
};
}

View file

@ -14,6 +14,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ path: "/docs/getting-started", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.9 },
{ path: "/docs/concepts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
{ path: "/docs/configuration", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
{ path: "/docs/custom-commands", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 },
{ path: "/docs/keyboard-shortcuts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 },
{ path: "/docs/api", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
{ path: "/docs/notifications", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
@ -27,9 +28,22 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ path: "/eula", lastModified: "2026-03-18", changeFrequency: "yearly" as const, priority: 0.3 },
];
// Legal pages are English-only (not translated), so they only get one entry.
const englishOnly = new Set(["/privacy-policy", "/terms-of-service", "/eula"]);
const entries: MetadataRoute.Sitemap = [];
for (const { path, lastModified, changeFrequency, priority } of paths) {
if (englishOnly.has(path)) {
entries.push({
url: `${base}${path}`,
lastModified,
changeFrequency,
priority,
});
continue;
}
const alternates: Record<string, string> = {};
for (const locale of locales) {
alternates[locale] =
@ -37,13 +51,18 @@ export default function sitemap(): MetadataRoute.Sitemap {
}
alternates["x-default"] = `${base}${path}`;
entries.push({
url: `${base}${path}`,
lastModified,
changeFrequency,
priority,
alternates: { languages: alternates },
});
// Emit a separate entry for each locale so Google sees every URL declared
for (const locale of locales) {
const url =
locale === "en" ? `${base}${path}` : `${base}/${locale}${path}`;
entries.push({
url,
lastModified,
changeFrequency,
priority,
alternates: { languages: alternates },
});
}
}
return entries;

22
web/i18n/seo.ts Normal file
View file

@ -0,0 +1,22 @@
import { locales } from "./routing";
const BASE = "https://cmux.com";
/**
* Build the full alternates object (canonical + hreflang languages)
* for a given locale and path. Use in every generateMetadata that
* sets alternates so child metadata doesn't wipe parent hreflang.
*/
export function buildAlternates(locale: string, path: string) {
const languages: Record<string, string> = {};
for (const loc of locales) {
languages[loc] =
loc === "en" ? `${BASE}${path}` : `${BASE}/${loc}${path}`;
}
languages["x-default"] = `${BASE}${path}`;
const canonical =
locale === "en" ? `${BASE}${path}` : `${BASE}/${locale}${path}`;
return { canonical, languages };
}

View file

@ -5,7 +5,6 @@ import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
const nextConfig: NextConfig = {
skipTrailingSlashRedirect: true,
images: {
remotePatterns: [
{

View file

@ -15,6 +15,25 @@ export default function middleware(request: NextRequest) {
return NextResponse.redirect(url.toString(), 301);
}
// Legal pages are English-only. Redirect /<locale>/legal-page to /legal-page,
// and skip next-intl for /legal-page so locale detection can't redirect back.
const legalPages = new Set(["/privacy-policy", "/terms-of-service", "/eula"]);
const { pathname } = request.nextUrl;
if (legalPages.has(pathname)) {
const url = request.nextUrl.clone();
url.pathname = `/en${pathname}`;
return NextResponse.rewrite(url);
}
const secondSlash = pathname.indexOf("/", 1);
if (secondSlash !== -1) {
const rest = pathname.slice(secondSlash);
if (legalPages.has(rest)) {
const url = request.nextUrl.clone();
url.pathname = rest;
return NextResponse.redirect(url, 301);
}
}
return intlMiddleware(request);
}