Skip to content
17 changes: 10 additions & 7 deletions web/app/[locale]/(legal)/eula/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "EULA — cmux",
description: "End-User License Agreement for cmux",
alternates: { canonical: "./" },
};
import { buildAlternates } from "../../../../i18n/seo";

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return {
title: "EULA — cmux",
description: "End-User License Agreement for cmux",
alternates: buildAlternates(locale, "/eula"),
};
}

export default function EulaPage() {
return (
Expand Down
15 changes: 9 additions & 6 deletions web/app/[locale]/(legal)/privacy-policy/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { Metadata } from "next";
import { buildAlternates } from "../../../../i18n/seo";
import { Link } from "../../../../i18n/navigation";

export const metadata: Metadata = {
title: "Privacy Policy — cmux",
description: "Privacy policy for cmux",
alternates: { canonical: "./" },
};
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return {
title: "Privacy Policy — cmux",
description: "Privacy policy for cmux",
alternates: buildAlternates(locale, "/privacy-policy"),
};
}

export default function PrivacyPolicyPage() {
return (
Expand Down
17 changes: 10 additions & 7 deletions web/app/[locale]/(legal)/terms-of-service/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Terms of Service — cmux",
description: "Terms of service for cmux",
alternates: { canonical: "./" },
};
import { buildAlternates } from "../../../../i18n/seo";

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return {
title: "Terms of Service — cmux",
description: "Terms of service for cmux",
alternates: buildAlternates(locale, "/terms-of-service"),
};
}

export default function TermsOfServicePage() {
return (
Expand Down
5 changes: 2 additions & 3 deletions web/app/[locale]/blog/cmd-shift-u/page.tsx
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -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"),
};
}

Expand Down
5 changes: 2 additions & 3 deletions web/app/[locale]/blog/introducing-cmux/page.tsx
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -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"),
};
}

Expand Down
5 changes: 2 additions & 3 deletions web/app/[locale]/blog/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,9 +20,7 @@ export async function generateMetadata({
siteName: "cmux",
type: "article" as const,
},
alternates: {
canonical: "./",
},
alternates: buildAlternates(locale, "/blog"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Blog layout canonical collides with individual post metadata

The blog layout emits alternates: buildAlternates(locale, "/blog"). Because individual blog post pages do define their own alternates they correctly override this. The /blog list page will also be correct since it has no page-level generateMetadata. This is fine — just noting it here for contrast with the docs situation above, where sub-pages do not override the layout canonical.

};
}

Expand Down
5 changes: 2 additions & 3 deletions web/app/[locale]/blog/show-hn-launch/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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";

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"),
Expand All @@ -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"),
};
}

Expand Down
5 changes: 2 additions & 3 deletions web/app/[locale]/blog/zen-of-cmux/page.tsx
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -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"),
};
}

Expand Down
3 changes: 2 additions & 1 deletion web/app/[locale]/community/page.tsx
Original file line number Diff line number Diff line change
@@ -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 }> }) {
Expand All @@ -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"),
};
}

Expand Down
5 changes: 2 additions & 3 deletions web/app/[locale]/docs/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -18,9 +19,7 @@ export async function generateMetadata({
siteName: "cmux",
type: "article" as const,
},
alternates: {
canonical: "./",
},
alternates: buildAlternates(locale, "/docs"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve per-page canonicals in docs layout

Setting alternates to buildAlternates(locale, "/docs") at the layout level makes every docs descendant (for example /docs/getting-started, /docs/api, etc.) emit canonical/hreflang URLs for /docs, because those pages do not define their own alternates. This causes multiple distinct docs pages to advertise the same canonical target, which can collapse indexing and undo the SEO fix for the docs section.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Docs sub-pages inherit wrong canonical from layout

The docs layout sets alternates: buildAlternates(locale, "/docs"), which Next.js App Router will merge into any docs page that doesn't return its own alternates. Looking at the individual docs pages (getting-started/page.tsx, configuration/page.tsx, api/page.tsx, etc.), they each define generateMetadata returning only { title, description } — no alternates. Next.js shallow-merges metadata up the segment tree, so all those sub-pages will inherit canonical: "https://cmux.com/docs" instead of their own URL.

Before this PR the layout used canonical: "./", which resolved against metadataBase: "https://cmux.com" to the homepage — also wrong. This PR improves it (now points to /docs rather than /), but the sub-pages still don't get self-referencing canonicals, which was one of the primary goals listed in the PR description.

To fully fix this, each docs page's generateMetadata should include its own buildAlternates call:

// e.g. getting-started/page.tsx
alternates: buildAlternates(locale, "/docs/getting-started"),

The same gap applies to concepts, configuration, api, notifications, changelog, keyboard-shortcuts, browser-automation, and custom-commands.

};
}

Expand Down
2 changes: 2 additions & 0 deletions web/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -61,6 +62,7 @@ export async function generateMetadata({
title: t("title"),
description: t("ogDescription"),
},
alternates: buildAlternates(locale, ""),
metadataBase: new URL("https://cmux.com"),
};
}
Expand Down
3 changes: 2 additions & 1 deletion web/app/[locale]/nightly/page.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -12,7 +13,7 @@ export async function generateMetadata({
return {
title: t("metaTitle"),
description: t("metaDescription"),
alternates: { canonical: "./" },
alternates: buildAlternates(locale, "/nightly"),
};
}

Expand Down
3 changes: 2 additions & 1 deletion web/app/[locale]/wall-of-love/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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"),
};
}

Expand Down
2 changes: 1 addition & 1 deletion web/app/robots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
}
20 changes: 13 additions & 7 deletions web/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -37,13 +38,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;
Expand Down
22 changes: 22 additions & 0 deletions web/i18n/seo.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
1 change: 0 additions & 1 deletion web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");

const nextConfig: NextConfig = {
skipTrailingSlashRedirect: true,
images: {
remotePatterns: [
{
Expand Down
Loading