Give each route its own title and canonical, and add robots.txt plus a sitemap.

The root layout was stamping the homepage title and production root onto every page. Routes now supply a page segment through one em-dash template, canonicals follow the request path, and non-production hosts disallow crawlers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-09-09 16:45:19 -06:00
co-authored by Cursor
parent e4e61c0c9b
commit b2b272d2cc
32 changed files with 573 additions and 72 deletions
+6
View File
@@ -1,8 +1,14 @@
import type { Metadata } from "next";
import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index";
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
export const metadata: Metadata = {
robots: NO_INDEX_ROBOTS,
};
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
// See `(app)/layout.tsx` for the matching `<Suspense fallback={null}>` rationale
+7
View File
@@ -1,5 +1,12 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
import MonitorPageContent from "./MonitorPageContent";
export const metadata: Metadata = routeMetadata("/monitor", {
title: messages.metadata.monitor.title,
});
export default function MonitorPage() {
return <MonitorPageContent />;
}
+11
View File
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
import { createFlowStepPath, CREATE_ROUTES } from "../utils/createFlowPaths";
import { isValidStep } from "../utils/flowSteps";
import type { CreateFlowStep } from "../types";
import { routeMetadata } from "../../../../lib/siteMetadata";
/**
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
@@ -14,6 +17,14 @@ interface PageProps {
params: Promise<{ screenId: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { screenId: raw } = await params;
const path = isValidStep(raw)
? createFlowStepPath(raw)
: `${CREATE_ROUTES.createRoot}/${raw}`;
return routeMetadata(path);
}
export default async function CreateFlowScreenPage({ params }: PageProps) {
const { screenId: raw } = await params;
+7
View File
@@ -1,5 +1,12 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = routeMetadata("/create", {
title: messages.metadata.create.title,
});
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
+6
View File
@@ -1,8 +1,14 @@
import type { Metadata } from "next";
import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index";
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
export const metadata: Metadata = {
robots: NO_INDEX_ROBOTS,
};
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
// `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()`
+6 -4
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = {
title: "Log in · CommunityRule",
robots: { index: false, follow: false },
};
export const metadata: Metadata = routeMetadata("/login", {
title: messages.metadata.login.title,
robots: NO_INDEX_ROBOTS,
});
export default function LoginLayout({
children,
+6 -4
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
import ProfilePageClient from "./ProfilePageClient";
export const metadata: Metadata = {
title: "Profile · CommunityRule",
robots: { index: false, follow: false },
};
export const metadata: Metadata = routeMetadata("/profile", {
title: messages.metadata.profile.title,
robots: NO_INDEX_ROBOTS,
});
export default function ProfilePage() {
return <ProfilePageClient />;
+6
View File
@@ -1,8 +1,14 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index";
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
export const metadata: Metadata = {
robots: NO_INDEX_ROBOTS,
};
// Development-only previews (e.g. `/components-preview`) — no public chrome.
export default function DevLayout({ children }: { children: ReactNode }) {
+6
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import {
ASSETS,
@@ -6,6 +7,7 @@ import {
structureBeforeCrisisPath,
} from "../../../lib/assetUtils";
import { getTranslation } from "../../../lib/i18n/getTranslation";
import { routeMetadata } from "../../../lib/siteMetadata";
import AboutHeader from "../../components/type/AboutHeader";
import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
import Stats from "../../components/sections/Stats";
@@ -18,6 +20,10 @@ import type { FaqAccordionItem } from "../../components/sections/Accordion";
import QuoteBlock from "../../components/sections/QuoteBlock";
import AskOrganizer from "../../components/sections/AskOrganizer";
export const metadata: Metadata = routeMetadata("/about", {
title: messages.metadata.about.title,
});
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : [];
}
+12 -10
View File
@@ -8,6 +8,8 @@ import {
getRelatedBlogPosts,
} from "../../../../lib/content";
import { logger } from "../../../../lib/logger";
import { routeMetadata } from "../../../../lib/siteMetadata";
import messages from "../../../../messages/en/index";
import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer";
import ContentTemplateDecorativeShapes from "../../_components/ContentTemplateDecorativeShapes";
@@ -62,13 +64,13 @@ export async function generateMetadata({
const post = getBlogPostBySlug(slug);
if (!post) {
return {
title: "Post Not Found",
description: "The requested blog post could not be found.",
};
return routeMetadata(`/blog/${slug}`, {
title: messages.metadata.blog.notFoundTitle,
description: messages.metadata.blog.fallbackDescription,
});
}
return {
return routeMetadata(`/blog/${slug}`, {
title: post.frontmatter.title,
description: post.frontmatter.description,
authors: [{ name: post.frontmatter.author }],
@@ -78,8 +80,8 @@ export async function generateMetadata({
type: "article",
publishedTime: post.frontmatter.date,
authors: [post.frontmatter.author],
url: `https://communityrule.com/blog/${slug}`,
siteName: "CommunityRule",
url: `/blog/${slug}`,
siteName: messages.metadata.siteName,
},
twitter: {
card: "summary_large_image",
@@ -87,12 +89,12 @@ export async function generateMetadata({
description: post.frontmatter.description,
creator: "@communityrule",
},
};
});
} catch (error) {
logger.error("Error generating metadata:", error);
return {
title: "Blog Post",
description: "A blog post from our community.",
title: messages.metadata.blog.fallbackTitle,
description: messages.metadata.blog.fallbackDescription,
};
}
}
+16 -16
View File
@@ -1,26 +1,27 @@
import { getAllBlogPosts } from "../../../lib/content";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = {
title: "Blog - CommunityRule",
description:
"Learn about community governance, decision-making, and building successful organizations.",
const blogMeta = messages.metadata.blog;
export const metadata: Metadata = routeMetadata("/blog", {
title: blogMeta.title,
description: blogMeta.description,
openGraph: {
title: "Blog - CommunityRule",
description:
"Learn about community governance, decision-making, and building successful organizations.",
url: "https://communityrule.com/blog",
siteName: "CommunityRule",
title: blogMeta.title,
description: blogMeta.description,
url: "/blog",
siteName: messages.metadata.siteName,
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Blog - CommunityRule",
description:
"Learn about community governance, decision-making, and building successful organizations.",
title: blogMeta.title,
description: blogMeta.description,
},
};
});
export default function BlogPage() {
const posts = getAllBlogPosts();
@@ -34,11 +35,10 @@ export default function BlogPage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4">
Blog
{blogMeta.title}
</h1>
<p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto">
Learn about community governance, decision-making, and building
successful organizations.
{blogMeta.description}
</p>
</div>
+5 -1
View File
@@ -5,6 +5,7 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import LegalDocumentPage, {
legalPageMetadata,
} from "../_components/LegalDocumentPage";
@@ -13,7 +14,10 @@ const SLUG = "cookies" as const;
const PATH = legalPathForSlug(SLUG);
export function generateMetadata(): Metadata {
return legalPageMetadata(messages.metadata.cookies, messages.pages.cookies);
return routeMetadata(
PATH,
legalPageMetadata(messages.metadata.cookies, messages.pages.cookies),
);
}
export default function CookiesPage() {
+4 -3
View File
@@ -10,6 +10,7 @@ import {
buildHowItWorksSyntheticPost,
HOW_IT_WORKS_SENTINEL_SLUG,
} from "../../../lib/howItWorksSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import ContentBanner from "../../components/sections/ContentBanner";
import ContentTemplateDecorativeShapes from "../_components/ContentTemplateDecorativeShapes";
import AskOrganizer from "../../components/sections/AskOrganizer";
@@ -29,7 +30,7 @@ export async function generateMetadata(): Promise<Metadata> {
const meta = messages.metadata.howItWorks;
const page = messages.pages.howItWorks;
return {
return routeMetadata("/how-it-works", {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
@@ -37,9 +38,9 @@ export async function generateMetadata(): Promise<Metadata> {
title: page.banner.title,
description: page.banner.description,
type: "website",
siteName: "CommunityRule",
siteName: messages.metadata.siteName,
},
};
});
}
export default function HowItWorksPage() {
+6
View File
@@ -1,10 +1,16 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { getTranslation } from "../../../lib/i18n/getTranslation";
import { routeMetadata } from "../../../lib/siteMetadata";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import ContentLockup from "../../components/type/ContentLockup";
import AskOrganizer from "../../components/sections/AskOrganizer";
import { getAllBlogPosts } from "../../../lib/content";
export const metadata: Metadata = routeMetadata("/learn", {
title: messages.metadata.learn.title,
});
export default function LearnPage() {
const allPosts = getAllBlogPosts();
+4
View File
@@ -1,11 +1,15 @@
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import { Suspense } from "react";
import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation";
import { routeMetadata } from "../../lib/siteMetadata";
import HeroBanner from "../components/sections/HeroBanner";
import AskOrganizer from "../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
export const metadata: Metadata = routeMetadata("/");
// Code split below-the-fold components to reduce initial bundle size
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
loading: () => (
+5 -1
View File
@@ -5,6 +5,7 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import LegalDocumentPage, {
legalPageMetadata,
} from "../_components/LegalDocumentPage";
@@ -13,7 +14,10 @@ const SLUG = "privacy" as const;
const PATH = legalPathForSlug(SLUG);
export function generateMetadata(): Metadata {
return legalPageMetadata(messages.metadata.privacy, messages.pages.privacy);
return routeMetadata(
PATH,
legalPageMetadata(messages.metadata.privacy, messages.pages.privacy),
);
}
export default function PrivacyPage() {
+5 -4
View File
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import messages from "../../../../messages/en/index";
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
import { routeMetadata } from "../../../../lib/siteMetadata";
import CommunityRule from "../../../components/type/CommunityRule";
import HeaderLockup from "../../../components/type/HeaderLockup";
@@ -25,22 +26,22 @@ export async function generateMetadata({
typeof rule.summary === "string" && rule.summary.trim().length > 0
? rule.summary
: undefined;
return {
return routeMetadata(`/rules/${rule.id}`, {
title: rule.title,
description,
openGraph: {
title: rule.title,
description,
type: "article",
url: `https://communityrule.com/rules/${rule.id}`,
siteName: "CommunityRule",
url: `/rules/${rule.id}`,
siteName: messages.metadata.siteName,
},
twitter: {
card: "summary_large_image",
title: rule.title,
description,
},
};
});
}
export default async function PublicRuleDetailPage({ params }: PageProps) {
+7
View File
@@ -1,7 +1,14 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { routeMetadata } from "../../../lib/siteMetadata";
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
import TemplatesPageClient from "./TemplatesPageClient";
export const metadata: Metadata = routeMetadata("/templates", {
title: messages.metadata.templates.title,
});
export default async function TemplatesPage() {
const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
+5 -1
View File
@@ -5,6 +5,7 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import LegalDocumentPage, {
legalPageMetadata,
} from "../_components/LegalDocumentPage";
@@ -13,7 +14,10 @@ const SLUG = "terms" as const;
const PATH = legalPathForSlug(SLUG);
export function generateMetadata(): Metadata {
return legalPageMetadata(messages.metadata.terms, messages.pages.terms);
return routeMetadata(
PATH,
legalPageMetadata(messages.metadata.terms, messages.pages.terms),
);
}
export default function TermsPage() {
+4 -3
View File
@@ -12,6 +12,7 @@ import {
USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug,
} from "../../../../lib/useCaseSyntheticPost";
import { routeMetadata } from "../../../../lib/siteMetadata";
import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer";
import type { AskOrganizerVariant } from "../../../components/sections/AskOrganizer/AskOrganizer.types";
@@ -34,7 +35,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const contentKey = useCaseContentKeyForSlug(slug);
const meta = messages.metadata.useCasesDetail[contentKey];
return {
return routeMetadata(`/use-cases/${slug}`, {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
@@ -42,9 +43,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: meta.title,
description: meta.description,
type: "website",
siteName: "CommunityRule",
siteName: messages.metadata.siteName,
},
};
});
}
export default async function UseCaseDetailPage({ params }: PageProps) {
+4 -3
View File
@@ -5,6 +5,7 @@ import { Suspense } from "react";
import messages from "../../../messages/en/index";
import { CONTENT_CATALOG_SLUG_ORDER } from "../../../lib/assetUtils";
import { getAllBlogPosts, getRelatedBlogPosts } from "../../../lib/content";
import { routeMetadata } from "../../../lib/siteMetadata";
import PageHeader from "../../components/type/PageHeader";
import CaseStudy from "../../components/cards/CaseStudy";
import UseCasesOrgs from "../../components/sections/UseCasesOrgs";
@@ -60,7 +61,7 @@ export async function generateMetadata(): Promise<Metadata> {
const description = messages.metadata.useCases.description;
const keywords = messages.metadata.useCases.keywords;
return {
return routeMetadata("/use-cases", {
title,
description,
keywords,
@@ -68,9 +69,9 @@ export async function generateMetadata(): Promise<Metadata> {
title,
description,
type: "website",
siteName: "CommunityRule",
siteName: messages.metadata.siteName,
},
};
});
}
export default function UseCasesPage() {
@@ -6,6 +6,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import messages from "../../../../../messages/en/index";
import { resolveUseCaseCompletedRule } from "../../../../../lib/useCaseCompletedRule";
import { routeMetadata } from "../../../../../lib/siteMetadata";
import {
USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug,
@@ -33,7 +34,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const contentKey = useCaseContentKeyForSlug(resolved.slug);
const meta = messages.metadata.useCasesCompletedRule[contentKey];
return {
return routeMetadata(`/use-cases/${resolved.slug}/rule`, {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
@@ -41,9 +42,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: meta.title,
description: meta.description,
type: "website",
siteName: "CommunityRule",
siteName: messages.metadata.siteName,
},
};
});
}
export default async function UseCaseCompletedRulePage({ params }: PageProps) {
+8 -7
View File
@@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import messages from "../messages/en/index";
import { ASSETS, getAssetPath } from "../lib/assetUtils";
import { PRODUCTION_SITE_ORIGIN } from "../lib/siteMetadata";
import "./globals.css";
// `force-dynamic` is now scoped to `(app)/layout.tsx` and `(admin)/layout.tsx`
@@ -54,7 +55,10 @@ export const viewport: Viewport = {
};
export const metadata: Metadata = {
title: homeMeta.title,
title: {
default: homeMeta.title,
template: messages.metadata.titleTemplate,
},
description: homeMeta.description,
keywords: [...homeMeta.keywords],
authors: [{ name: "Media Economies Design Lab" }],
@@ -65,7 +69,7 @@ export const metadata: Metadata = {
address: false,
telephone: false,
},
metadataBase: new URL("https://communityrule.com"),
metadataBase: new URL(PRODUCTION_SITE_ORIGIN),
icons: {
icon: [
{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" },
@@ -89,14 +93,11 @@ export const metadata: Metadata = {
},
],
},
alternates: {
canonical: "/",
},
openGraph: {
title: homeMeta.title,
description: homeMeta.description,
url: "https://communityrule.com",
siteName: "CommunityRule",
url: PRODUCTION_SITE_ORIGIN,
siteName: messages.metadata.siteName,
locale: "en_US",
type: "website",
},
+7
View File
@@ -1,11 +1,18 @@
import type { Metadata } from "next";
import Link from "next/link";
import messages from "../messages/en/index";
import { getTranslation } from "../lib/i18n/getTranslation";
import { NO_INDEX_ROBOTS } from "../lib/siteMetadata";
import { getGovernanceTemplateCatalogEntry } from "../lib/templates/governanceTemplateCatalog";
import Icon from "./components/asset/icon";
import Button from "./components/buttons/Button";
import HeroDecor from "./components/sections/HeroBanner/HeroDecor";
export const metadata: Metadata = {
title: messages.metadata.notFound.title,
robots: NO_INDEX_ROBOTS,
};
const NOT_FOUND_TEMPLATE_SLUGS = [
"consensus",
"do-ocracy",
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
import { headers } from "next/headers";
import { buildRobots, resolveRequestHost } from "../lib/siteMetadata";
export default async function robots(): Promise<MetadataRoute.Robots> {
const headerStore = await headers();
const host = resolveRequestHost(
headerStore.get("x-forwarded-host"),
headerStore.get("host"),
);
return buildRobots(host);
}
+6
View File
@@ -0,0 +1,6 @@
import type { MetadataRoute } from "next";
import { buildPublicSitemap } from "../lib/publicSitemap";
export default function sitemap(): MetadataRoute.Sitemap {
return buildPublicSitemap();
}