Skip to content

feat(core): add canonical URLs and hreflang alternates for SEO#2856

Merged
jorgemoya merged 6 commits intocanaryfrom
CATALYST-1693-add-canonical-tags
Feb 11, 2026
Merged

feat(core): add canonical URLs and hreflang alternates for SEO#2856
jorgemoya merged 6 commits intocanaryfrom
CATALYST-1693-add-canonical-tags

Conversation

@jorgemoya
Copy link
Contributor

What/Why?

Add canonical URLs and hreflang alternates for SEO. Pages now set alternates.canonical and alternates.languages in generateMetadata via the new getMetadataAlternates helper in core/lib/seo/canonical.ts. The default locale uses no path prefix; other locales use /{locale}/path. The root locale layout sets metadataBase to the configured vanity URL so canonical URLs resolve correctly.

Testing

Screenshot 2026-01-30 at 3 22 42 PM Screenshot 2026-01-30 at 3 23 17 PM Screenshot 2026-01-30 at 3 23 26 PM Screenshot 2026-01-30 at 3 24 15 PM Screenshot 2026-01-30 at 3 24 41 PM

Migration steps

Step 1: Root layout metadata base

Set metadataBase in the root locale layout so canonical URLs resolve to your vanity URL.

Update core/app/[locale]/layout.tsx:

  import { Providers } from '~/app/providers';
+ import { buildConfig } from '~/build-config/reader';
  import { client } from '~/client';
  ...
  return {
+   metadataBase: new URL(buildConfig.get('urls').vanityUrl),
    title: {

Step 2: GraphQL fragment updates

Add the path field to brand, blog post, and product queries so metadata can build canonical URLs.

Update core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts:

  site {
    brand(entityId: $entityId) {
      name
+     path
      seo {

Update core/app/[locale]/(default)/blog/[blogId]/page-data.ts:

  author
  htmlBody
  name
+ path
  publishedDate {

Update core/app/[locale]/(default)/product/[slug]/page-data.ts (in the metadata query):

  site {
    product(entityId: $entityId) {
      name
+     path
      defaultImage {

Step 3: Page metadata alternates

Add the getMetadataAlternates import and set alternates in generateMetadata for each page. Ensure core/lib/seo/canonical.ts exists (it is included in this release).

Update core/app/[locale]/(default)/page.tsx (home):

+ import { Metadata } from 'next';
  import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server';
  ...
+ import { getMetadataAlternates } from '~/lib/seo/canonical';
  ...
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
+   const { locale } = await params;
+   return {
+     alternates: getMetadataAlternates({ path: '/', locale }),
+   };
+ }
+
  export default async function Home({ params }: Props) {

For entity pages (product, category, brand, blog, blog post, webpage), add the import and include alternates in the existing generateMetadata return value using the entity path (or breadcrumb-derived path for category and webpage). Example for a brand page:

+ import { getMetadataAlternates } from '~/lib/seo/canonical';
  ...
  export async function generateMetadata(props: Props): Promise<Metadata> {
-   const { slug } = await props.params;
+   const { slug, locale } = await props.params;
    ...
    return {
      title: pageTitle || brand.name,
      description: metaDescription,
      keywords: metaKeywords ? metaKeywords.split(',') : null,
+     alternates: getMetadataAlternates({ path: brand.path, locale }),
    };
  }

Step 4: Gift certificates pages

Update core/app/[locale]/(default)/gift-certificates/page.tsx:

+ import { getMetadataAlternates } from '~/lib/seo/canonical';
  ...
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
    const { locale } = await params;
    const t = await getTranslations({ locale, namespace: 'GiftCertificates' });

    return {
      title: t('title') || 'Gift certificates',
+     alternates: getMetadataAlternates({ path: '/gift-certificates', locale }),
    };
  }

Update core/app/[locale]/(default)/gift-certificates/balance/page.tsx:

+ import { getMetadataAlternates } from '~/lib/seo/canonical';
  ...
    return {
      title: t('title') || 'Gift certificates - Check balance',
+     alternates: getMetadataAlternates({ path: '/gift-certificates/balance', locale }),
    };

Add generateMetadata to core/app/[locale]/(default)/gift-certificates/purchase/page.tsx:

+ import { Metadata } from 'next';
  import { getFormatter, getTranslations } from 'next-intl/server';
  ...
+ import { getMetadataAlternates } from '~/lib/seo/canonical';
  ...
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
+   const { locale } = await params;
+   const t = await getTranslations({ locale, namespace: 'GiftCertificates' });
+
+   return {
+     title: t('Purchase.title'),
+     alternates: getMetadataAlternates({ path: '/gift-certificates/purchase', locale }),
+   };
+ }

Step 5: Contact page

Update core/app/[locale]/(default)/webpages/[id]/contact/page.tsx:

+ import { getMetadataAlternates } from '~/lib/seo/canonical';
  ...
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
-   const { id } = await params;
+   const { id, locale } = await params;
    const webpage = await getWebPage(id);
    const { pageTitle, metaDescription, metaKeywords } = webpage.seo;

    return {
      title: pageTitle || webpage.title,
      description: metaDescription,
      keywords: metaKeywords ? metaKeywords.split(',') : null,
+     alternates: getMetadataAlternates({ path: webpage.path, locale }),
    };
  }

Step 6: Public wishlist page

Update core/app/[locale]/(default)/wishlist/[token]/page.tsx:

+ import { getMetadataAlternates } from '~/lib/seo/canonical';
  ...
  export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
    const { locale, token } = await params;
    ...
    return {
      title: wishlist?.name ?? t('title'),
+     alternates: getMetadataAlternates({ path: `/wishlist/${token}`, locale }),
    };
  }

Step 7: Compare page

Update core/app/[locale]/(default)/compare/page.tsx:

+ import { getMetadataAlternates } from '~/lib/seo/canonical';
  ...
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
    const { locale } = await params;
    const t = await getTranslations({ locale, namespace: 'Compare' });

    return {
      title: t('title'),
+     alternates: getMetadataAlternates({ path: '/compare', locale }),
    };
  }

@jorgemoya jorgemoya requested a review from a team as a code owner January 30, 2026 23:12
@changeset-bot
Copy link

changeset-bot bot commented Jan 30, 2026

🦋 Changeset detected

Latest commit: eb1b56e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@bigcommerce/catalyst-core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link

vercel bot commented Jan 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
catalyst Ready Ready Preview, Comment Feb 11, 2026 7:28pm

Request Review

params: Promise<{ locale: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Were we not setting any of the metadata on this page previously?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I guess we are setting default metadata in the layout file and this just adds/overrwrides any additional fields?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, exactly

@jamesqquick
Copy link
Contributor

Confirmed seeing the generated canonical and alternate urls locally

const { pageTitle, metaDescription, metaKeywords } = data.site.settings?.seo || {};

return {
metadataBase: new URL(buildConfig.get('urls').vanityUrl),
Copy link
Contributor

Choose a reason for hiding this comment

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

This will only be the default channel, but for domain-based localization I think this will break, right? For example if the merchant configured it to be (channel id/url) 123/us.example.com + 456/es.example.com + etc...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looking into this, any ideas?

Copy link
Contributor

Choose a reason for hiding this comment

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

You can query for the vanityUrl on each request 🤔

export function getMetadataAlternates(options: CanonicalUrlOptions) {
const { path, locale, includeAlternates = true } = options;

const vanityUrl = buildConfig.get('urls').vanityUrl.replace(/\/$/, '');
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment here.

jorgemoya and others added 4 commits February 10, 2026 15:29
Add canonical meta tags to gift certificates, contact, public wishlist,
and compare pages. Update changeset with migration steps for all pages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add JSDoc param and returns type annotations
- Replace for...of loop with reduce for array iteration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

@chanceaclark chanceaclark left a comment

Choose a reason for hiding this comment

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

Overall the approach is way better, just some comments around simplifying the code with the URL object.

@jorgemoya jorgemoya force-pushed the CATALYST-1693-add-canonical-tags branch from b711e89 to eb1b56e Compare February 11, 2026 19:26
@jorgemoya jorgemoya added this pull request to the merge queue Feb 11, 2026
Merged via the queue into canary with commit f5330c7 Feb 11, 2026
11 checks passed
@jorgemoya jorgemoya deleted the CATALYST-1693-add-canonical-tags branch February 11, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants