← Back to bills
@@ -152,15 +185,28 @@ export async function generateMetadata(
const { id } = await params;
const sp = await searchParams;
const q = sp?.q;
- const title = id;
- const description = `Bill ${id} analysis and judgement`;
- const h = headers();
- const host = (await h).get("x-forwarded-host") || (await h).get("host") || "";
- const proto = ((await h).get("x-forwarded-proto") || "https").split(",")[0];
- // Ensure we always have a base URL for absolute image URLs (required for Twitter Cards)
- const baseUrl =
- env.NEXT_PUBLIC_APP_URL ||
- (host ? `${proto}://${host}` : "http://localhost:3000");
+
+ // Memoized — shares the fetch with the page component for this request.
+ const bill = await getUnifiedBillById(id);
+
+ // Don't index "bill not found" pages.
+ if (!bill) {
+ return {
+ title: id,
+ description: `Bill ${id} analysis and judgement`,
+ robots: { index: false, follow: false },
+ };
+ }
+
+ const billName = bill.short_title || bill.title;
+ const title = billName ? truncate(`${id}: ${billName}`, 70) : id;
+ const description = bill.summary
+ ? truncate(stripMarkdown(bill.summary), 155)
+ : `Bill ${id} analysis and judgement`;
+ const keywords =
+ bill.genres && bill.genres.length > 0 ? bill.genres : undefined;
+
+ const baseUrl = getCanonicalOrigin();
const pagePath = buildRelativePath(id);
const pageUrl = `${baseUrl}${pagePath}`;
const pageUrlWithQuery = q
@@ -184,6 +230,7 @@ export async function generateMetadata(
return {
title,
description,
+ keywords,
alternates: { canonical: pageUrl },
openGraph: {
title,
@@ -200,15 +247,5 @@ export async function generateMetadata(
site: BUILD_CANADA_TWITTER_HANDLE,
images: [ogImageUrl],
},
- other: {
- "twitter:card": "summary_large_image",
- "twitter:title": title,
- "twitter:description": description,
- "twitter:image": ogImageUrl,
- "twitter:image:alt": `Analysis card for Bill ${title}`,
- "twitter:creator": BUILD_CANADA_TWITTER_HANDLE,
- "twitter:site": BUILD_CANADA_TWITTER_HANDLE,
- "twitter:url": pageUrlWithQuery,
- },
};
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e7e37c3..818eba7 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,7 +3,6 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Footer } from "@/components/Footer/footer.component";
import { SessionProvider } from "@/components/SessionProvider";
-import { env } from "@/env";
import { GoogleAnalytics } from "@next/third-parties/google";
import {
BUILD_CANADA_TWITTER_HANDLE,
@@ -13,6 +12,7 @@ import {
import { Nav } from "@/components/Nav/nav.component";
import { SimpleAnalytics } from "@/components/SimpleAnalytics";
import { Toaster } from "@/components/ui/sonner";
+import { getCanonicalOrigin } from "@/utils/seo";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -30,9 +30,7 @@ export const metadata: Metadata = {
template: `%s · ${PROJECT_NAME}`,
},
description: "Understand Canadian Federal Bills",
- metadataBase: env.NEXT_PUBLIC_APP_URL
- ? new URL(env.NEXT_PUBLIC_APP_URL)
- : undefined,
+ metadataBase: new URL(getCanonicalOrigin()),
openGraph: {
type: "website",
siteName: PROJECT_NAME,
@@ -56,11 +54,6 @@ export const metadata: Metadata = {
description: "Understand Canadian Federal Bills",
images: ["https://buildcanada.com/bills/builder-mp-seo-image.png"],
},
- other: {
- "twitter:card": "summary_large_image",
- "twitter:site": BUILD_CANADA_TWITTER_HANDLE,
- "twitter:creator": BUILD_CANADA_TWITTER_HANDLE,
- },
};
export default async function RootLayout({
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 317e52e..9cf31ff 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,25 +1,21 @@
import { BillSummary } from "./types";
import BillExplorer from "./BillExplorer";
-import { getAllBillsFromDB } from "@/server/get-all-bills-from-db";
-import { fromBuildCanadaDbBill } from "@/utils/billConverters";
+import { getMergedBills } from "@/server/get-merged-bills";
import { getParliament45Header } from "@/components/BillDetail/BillHeader";
+import { JsonLd } from "@/components/JsonLd";
import Markdown from "react-markdown";
import type { Metadata } from "next";
-import { headers } from "next/headers";
-import { env } from "@/env";
import { buildRelativePath } from "@/utils/basePath";
-import { BUILD_CANADA_TWITTER_HANDLE, PROJECT_NAME } from "@/consts/general";
+import { getCanonicalOrigin } from "@/utils/seo";
+import {
+ BUILD_CANADA_TWITTER_HANDLE,
+ BUILD_CANADA_URL,
+ PROJECT_NAME,
+} from "@/consts/general";
import FAQModalTrigger from "./FAQModalTrigger";
-const CANADIAN_PARLIAMENT_NUMBER = 45;
type HomeSearchParams = { cache?: string };
-function toIsoString(value?: Date | string): string | undefined {
- if (!value) return undefined;
- const parsed = value instanceof Date ? value : new Date(value);
- return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
-}
-
// Force runtime generation (avoid build-time pre-render) and cache in-memory.
export const dynamic = "auto";
// Next.js requires route segment configs to be literal values (not imported constants)
@@ -29,14 +25,7 @@ export async function generateMetadata(): Promise
{
const title = "Home";
const description =
"Understand Canadian federal bills with builder-first analysis.";
- const h = headers();
- const headerList = await h;
- const host =
- headerList.get("x-forwarded-host") || headerList.get("host") || "";
- const proto = (headerList.get("x-forwarded-proto") || "https").split(",")[0];
- const baseUrl =
- env.NEXT_PUBLIC_APP_URL ||
- (host ? `${proto}://${host}` : "http://localhost:3000");
+ const baseUrl = getCanonicalOrigin();
const pagePath = buildRelativePath();
const pageUrl = `${baseUrl}${pagePath}`;
const ogPath = buildRelativePath("opengraph-image");
@@ -64,128 +53,12 @@ export async function generateMetadata(): Promise {
site: BUILD_CANADA_TWITTER_HANDLE,
images: [ogImageUrl],
},
- other: {
- "twitter:card": "summary_large_image",
- "twitter:title": PROJECT_NAME,
- "twitter:description": description,
- "twitter:image": ogImageUrl,
- "twitter:image:alt": PROJECT_NAME,
- "twitter:creator": BUILD_CANADA_TWITTER_HANDLE,
- "twitter:site": BUILD_CANADA_TWITTER_HANDLE,
- "twitter:url": pageUrl,
- },
};
}
const shouldUseLocalCache = process.env.NODE_ENV === "production";
let mergedBillsCache: { data: BillSummary[]; expiresAt: number } | null = null;
-async function getApiBills(): Promise {
- try {
- const response = await fetch(
- `${env.CIVICS_PROJECT_BASE_URL}/canada/bills/${CANADIAN_PARLIAMENT_NUMBER}`,
- {
- // Cache for 5 minutes in production, no cache in development
- ...(process.env.NODE_ENV === "production"
- ? { next: { revalidate: 300 } }
- : { cache: "no-store" }),
- headers: {
- "Content-Type": "application/json",
- Authorization: env.CIVICS_PROJECT_API_KEY
- ? `Bearer ${env.CIVICS_PROJECT_API_KEY}`
- : "",
- },
- },
- );
- if (!response.ok) {
- throw new Error("Failed to fetch bills from API");
- }
- const { data } = await response.json();
- return Array.isArray(data) ? (data as BillSummary[]) : (data?.bills ?? []);
- } catch (error) {
- console.error("Error fetching API bills:", error);
- return [];
- }
-}
-
-async function getMergedBills(): Promise {
- const apiBills = await getApiBills();
- const uri = process.env.MONGO_URI || "";
- const hasValidMongoUri =
- uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://");
- const dbBills = hasValidMongoUri ? await getAllBillsFromDB() : [];
-
- // Convert DB bills to UnifiedBill format first, then to BillSummary
- const dbBillsAsUnified = dbBills.map(fromBuildCanadaDbBill);
-
- // Create a map of DB bills by billId for quick lookup
- const dbBillsMap = new Map(
- dbBillsAsUnified.map((bill) => [bill.billId, bill]),
- );
-
- // Merge API bills with DB data
- const mergedBills: BillSummary[] = apiBills.map((apiBill) => {
- const dbBill = dbBillsMap.get(apiBill.billID);
-
- if (dbBill) {
- // Merge API bill with DB data (DB data takes precedence for analysis fields)
- return {
- ...dbBill,
- ...apiBill,
- shortTitle: dbBill.short_title || apiBill.shortTitle,
- summary: dbBill.summary,
- isSocialIssue: dbBill.isSocialIssue,
- final_judgment: dbBill.final_judgment as BillSummary["final_judgment"],
- rationale: dbBill.rationale,
- needs_more_info: dbBill.needs_more_info,
- missing_details: dbBill.missing_details,
- genres: dbBill.genres,
- parliamentNumber: dbBill.parliamentNumber,
- sessionNumber: dbBill.sessionNumber,
- };
- }
-
- // Return API bill as-is if no DB data
- return apiBill;
- });
-
- // Add any DB-only bills that aren't in the API response
- for (const [billId, dbBill] of dbBillsMap) {
- if (!mergedBills.find((bill) => bill.billID === billId)) {
- // Convert DB bill to BillSummary format
- const billSummary: BillSummary = {
- billID: dbBill.billId,
- title: dbBill.title,
- shortTitle: dbBill.short_title,
- stages: dbBill.stages || [],
- description: dbBill.summary || "",
- status: (dbBill.status as BillSummary["status"]) || "Introduced",
- sponsorParty: dbBill.sponsorParty || "Unknown",
- sponsorName: "Unknown",
- chamber:
- (dbBill.chamber as "House of Commons" | "Senate") ||
- "House of Commons",
- introducedOn:
- toIsoString(dbBill.introducedOn) || new Date().toISOString(),
- lastUpdatedOn:
- toIsoString(dbBill.lastUpdatedOn) || new Date().toISOString(),
- summary: dbBill.summary,
- isSocialIssue: dbBill.isSocialIssue,
- final_judgment: dbBill.final_judgment as BillSummary["final_judgment"],
- rationale: dbBill.rationale,
- needs_more_info: dbBill.needs_more_info,
- missing_details: dbBill.missing_details,
- genres: dbBill.genres,
- parliamentNumber: dbBill.parliamentNumber,
- sessionNumber: dbBill.sessionNumber,
- };
- mergedBills.push(billSummary);
- }
- }
-
- return mergedBills;
-}
-
async function getMergedBillsCached(): Promise {
if (!shouldUseLocalCache) {
// Avoid stale data while iterating locally; always hit the backing store.
@@ -214,8 +87,23 @@ export default async function Home({
mergedBillsCache = null; // Allow manual cache busting with ?cache=clear
}
const bills = await getMergedBillsCached();
+ const homeUrl = `${getCanonicalOrigin()}${buildRelativePath()}`;
+ const websiteJsonLd = {
+ "@context": "https://schema.org",
+ "@type": "WebSite",
+ name: PROJECT_NAME,
+ description:
+ "Understand Canadian federal bills with builder-first analysis.",
+ url: homeUrl,
+ publisher: {
+ "@type": "Organization",
+ name: "Build Canada",
+ url: BUILD_CANADA_URL,
+ },
+ };
return (
+
diff --git a/src/app/robots.ts b/src/app/robots.ts
new file mode 100644
index 0000000..7a8c5d3
--- /dev/null
+++ b/src/app/robots.ts
@@ -0,0 +1,22 @@
+import type { MetadataRoute } from "next";
+import { buildRelativePath } from "@/utils/basePath";
+import { getCanonicalOrigin } from "@/utils/seo";
+
+// Served at /bills/robots.txt.
+export default function robots(): MetadataRoute.Robots {
+ const origin = getCanonicalOrigin();
+ return {
+ rules: {
+ userAgent: "*",
+ allow: buildRelativePath(),
+ disallow: [
+ buildRelativePath("sign-in"),
+ buildRelativePath("unauthorized"),
+ `${buildRelativePath("dev")}/`,
+ `${buildRelativePath("api")}/`,
+ `${buildRelativePath()}/*/edit`,
+ ],
+ },
+ sitemap: `${origin}${buildRelativePath("sitemap.xml")}`,
+ };
+}
diff --git a/src/app/robots.txt b/src/app/robots.txt
deleted file mode 100644
index 34a7220..0000000
--- a/src/app/robots.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-User-agent: *
-Allow: /
-
-User-agent: Twitterbot
-Allow: /
-
-Sitemap: https://buildcanada.com/sitemap.xml
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
new file mode 100644
index 0000000..1179df6
--- /dev/null
+++ b/src/app/sitemap.ts
@@ -0,0 +1,41 @@
+import type { MetadataRoute } from "next";
+import { getMergedBills } from "@/server/get-merged-bills";
+import { buildRelativePath } from "@/utils/basePath";
+import { getCanonicalOrigin } from "@/utils/seo";
+
+// Served at /bills/sitemap.xml. Next.js does NOT prefix basePath into the URL
+// strings, so we build fully-qualified canonical URLs via the helpers below.
+export const revalidate = 3600; // 1 hour
+
+function toDate(value?: string | Date): Date | undefined {
+ if (!value) return undefined;
+ const parsed = value instanceof Date ? value : new Date(value);
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
+}
+
+export default async function sitemap(): Promise {
+ const origin = getCanonicalOrigin();
+ const homeEntry: MetadataRoute.Sitemap[number] = {
+ url: `${origin}${buildRelativePath()}`,
+ changeFrequency: "hourly",
+ priority: 1,
+ };
+
+ try {
+ const bills = await getMergedBills();
+ const billEntries: MetadataRoute.Sitemap = bills
+ .filter((bill) => Boolean(bill.billID))
+ .map((bill) => ({
+ url: `${origin}${buildRelativePath(bill.billID)}`,
+ lastModified: toDate(bill.lastUpdatedOn) ?? toDate(bill.introducedOn),
+ changeFrequency: "daily",
+ priority: 0.8,
+ }));
+
+ return [homeEntry, ...billEntries];
+ } catch (error) {
+ console.error("Error generating sitemap:", error);
+ // Never 500 the sitemap — fall back to the home entry.
+ return [homeEntry];
+ }
+}
diff --git a/src/components/JsonLd.tsx b/src/components/JsonLd.tsx
new file mode 100644
index 0000000..d5ea1f5
--- /dev/null
+++ b/src/components/JsonLd.tsx
@@ -0,0 +1,15 @@
+/**
+ * Renders a schema.org JSON-LD block. Server component — emits a single
+ *