Tutorials Astro & Next.js Series Chapter 3

Canonical URLs, Route Params, and the SEO Things People Forget

Astro + Next.jsChapter 3 of the Astro & Next.js Series24 minMay 31, 2026Beginner

Chapter 2 gave you the file → URL mapping. The next thing every real site needs is the right URL for each page — singular, canonical, indexable. It's the difference between Google ranking your /about page or splitting its authority across /about, /about/, /About, and /about?utm=twitter as four separate pages with one-quarter the weight each.

This chapter is the SEO-and-routing fix-it list. Every item is small; together they take a one-day-old site from "Google ignores us" to "ranking properly within a week."

Route Params vs Query Params — and Why It Matters

Two ways to put dynamic data in a URL:

Route paramQuery param
Looks like/blog/my-post/blog?slug=my-post
Framework feature[slug] filename (Ch 2)Read via URLSearchParams in code
Pre-renderable at build timeYes (generateStaticParams / getStaticPaths)No — query strings aren't part of the file
Cached by CDNYes (each URL is its own cache key)Usually treated as one URL — risky
SEO indexableYes — Google sees one URL per resourceOften deduplicated — many URLs collapse to one
Best forIdentity ("which post?")Filters ("how is it sorted/filtered?")

The rule of thumb: route params identify the resource; query params modify how it's presented. Same article, different sort order? /posts/launch?sort=new. Different article? /posts/some-other-thing.

Get this backwards — /article?slug=launch&category=swift for the identity of each article — and you've lost on every dimension at once: no static pre-rendering, poor CDN caching, weak SEO, ugly URLs. The framework chapter (Ch 2) is structured around this principle; this chapter just names it.

The Canonical URL — One Tag, Big Effect

Search engines treat every distinct URL as a distinct page. /about, /about/, /about?utm_source=twitter, and /About are four pages to Google unless you tell it otherwise.

The way you tell it otherwise is one HTML element in your page's <head>:

<link rel="canonical" href="https://example.com/about">

That says, in effect: "no matter what URL was used to reach this page, treat this URL as the real one for indexing, link equity, and search results." Add it to every page that could be reached by more than one URL — which is almost every page.

Setting canonical in Astro

---
// src/pages/articles/[slug].astro
const { slug } = Astro.params;
const article = await loadArticle(slug);
const canonical = new URL(`/articles/${slug}`, Astro.site).toString();
---
<html>
  <head>
    <title>{article.title}</title>
    <link rel="canonical" href={canonical}>
  </head>
  <body>…</body>
</html>

Astro.site is configured in astro.config.mjs (site: "https://example.com") and gives you the real production host even in dev.

Setting canonical in Next.js (App Router)

// app/articles/[slug]/page.tsx
import type { Metadata } from "next";
 
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params;
  return {
    title: (await loadArticle(slug)).title,
    alternates: { canonical: `https://example.com/articles/${slug}` },
  };
}
 
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  // … render
}

Next.js builds the <link rel="canonical"> automatically from metadata.alternates.canonical. You never write the tag by hand.

Telling the Framework Which Slugs Exist

A dynamic route file ([slug]/page.tsx) needs to know which slug values to pre-render. Both frameworks have a dedicated function for this:

Astro — getStaticPaths

---
// src/pages/articles/[slug].astro
export async function getStaticPaths() {
  const articles = await loadAllArticles();
  return articles.map(a => ({ params: { slug: a.slug } }));
}
 
const { slug } = Astro.params;
const article = await loadArticle(slug);
---
<h1>{article.title}</h1>

getStaticPaths returns an array of { params: { ... } } — one per URL to build. Astro pre-renders each at build time and ships static HTML.

Next.js — generateStaticParams

// app/articles/[slug]/page.tsx
export async function generateStaticParams() {
  const articles = await loadAllArticles();
  return articles.map(a => ({ slug: a.slug }));
}
 
export default async function Page({ params }) {
  const { slug } = await params;
  const article = await loadArticle(slug);
  return <h1>{article.title}</h1>;
}

Same idea, slightly different shape: return an array of { slug: ... } objects (no nesting under params). Next.js pre-renders one HTML per object at build time.

This is exactly how every chapter on this site is built: src/lib/articles.ts reads src/content/<series>/*.mdx, the slug-route file's generateStaticParams returns one entry per .mdx filename, and Next builds one HTML per chapter at deploy time.

Trailing Slash — Pick One and Stick

Is the URL /about or /about/? Pick one and 301-redirect the other. Either choice is fine; inconsistency is what hurts you, because each variant gets indexed separately and they compete with each other.

Astro

// astro.config.mjs
export default defineConfig({
  trailingSlash: "never",   // or "always" or "ignore"
});

"never" is the cleaner default for content sites: /about/ 301-redirects to /about.

Next.js

// next.config.js
module.exports = {
  trailingSlash: false,     // default — `/about` is canonical
};

Set this once, day one. Don't change it after launch — every existing inbound link suddenly becomes a 301, which is recoverable but ugly.

Redirects — 301 vs 302 vs 308 vs 307

When you move content, you redirect. The HTTP status code matters:

CodeMeaningUse for
301 Moved PermanentlyPermanent — search engines update their index.Renamed slug, retired page → its replacement.
302 FoundTemporary — search engines keep the old URL indexed."Down for maintenance" / A/B test landing.
307 TemporaryLike 302 but preserves the HTTP method (POST stays POST).Modern temporary redirects, especially for API endpoints.
308 PermanentLike 301 but preserves the method.Modern permanent redirects.

Next.js — redirects() in config

// next.config.js
module.exports = {
  async redirects() {
    return [
      { source: "/old-name", destination: "/new-name", permanent: true },   // 308
      { source: "/blog/:slug", destination: "/articles/:slug", permanent: true },
    ];
  },
};

Astro — middleware or static

For per-request: middleware (src/middleware.ts). For pre-renderable redirects:

// astro.config.mjs
export default defineConfig({
  redirects: {
    "/old-name": "/new-name",
    "/blog/[slug]": "/articles/[slug]",
  },
});

Open Graph Tags — How Social Shares Look

When someone pastes your URL into Twitter / Slack / Discord, the link unfurls into a card with a title, description, and image. That's pulled from Open Graph tags in your <head>:

<meta property="og:title" content="Canonical URLs, Route Params, and the SEO Things People Forget">
<meta property="og:description" content="Every dynamic page can be accessed multiple ways…">
<meta property="og:image" content="https://example.com/og/canonical-urls.png">
<meta property="og:url" content="https://example.com/articles/canonical-urls">
<meta property="og:type" content="article">

Both frameworks let you set these via the same mechanism that sets <title>:

// Next.js
export const metadata = {
  title: "…",
  openGraph: {
    title: "…",
    description: "…",
    images: ["https://example.com/og/canonical-urls.png"],
    url: "https://example.com/articles/canonical-urls",
    type: "article",
  },
};

Astro: put <meta property="og:…"> tags in your <head> directly, computed from frontmatter or props. There's also an astro-seo integration that wraps this up.

Sitemap + robots.txt

Two files that every site needs:

Both frameworks have first-class integrations:

Submit the sitemap once in Google Search Console. After that, every new build's sitemap is auto-discovered.

The Checklist Per Page

If a page passes all of these, it's correctly indexable:

CheckWhat right looks like
Has a unique <title>50–60 chars, descriptive, includes the primary keyword once.
Has a unique meta description140–160 chars, summarises the page.
Has a canonical link<link rel="canonical" href="https://example.com/…"> pointing at the production URL.
Has Open Graph tagsog:title, og:description, og:image, og:url.
Status is 200 (or 301 to the right place)No 302s for permanent moves; no soft 404s.
Listed in sitemap.xmlOnce, at the canonical URL.
Trailing slash matches site policyEither always or never; redirects enforce.

Mental Model — Three Sentences

  1. Use route params for identity (/blog/[slug]) and query params for filters (?sort=new) — getting that backwards costs you static pre-rendering, CDN cache hits, and SEO ranking.
  2. Every page that can be reached via more than one URL needs a <link rel="canonical"> pointing at its production URL — Astro builds it from Astro.site; Next.js builds it from metadata.alternates.canonical.
  3. Pre-render dynamic routes with generateStaticParams (Next.js) or getStaticPaths (Astro) and stick to one trailing-slash policy, one redirect status code (301/308 for permanent), and one canonical host across every link in the site.

Try It Yourself (15 Minutes)

  1. In any Next.js project, add alternates: { canonical: "..." } to one page's metadata. Build, view-source the HTML, find the <link rel="canonical"> tag. Inspect with Rich Results Test.
  2. In an Astro project, set site: in astro.config.mjs and add a <link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).toString()}> to your layout. Confirm in dev mode that view-source has the right URL.
  3. Decide your trailing-slash policy. Set trailingSlash in the config. Visit both /about and /about/ and confirm one 301s to the other.
  4. Add app/sitemap.ts (Next.js) or install @astrojs/sitemap. Build, open dist/sitemap.xml or visit /sitemap.xml in dev mode. Submit it to Google Search Console.
  5. Pick one currently-mis-keyed URL on a real project — anywhere you're using a query param for identity. Refactor it to a route param. Notice that you now get free pre-rendering, free caching, and free SEO.

Where This Lands in the Series

Your routes are now canonical, indexable, and CDN-cacheable. The last big piece is content rendering: how do you take Markdown / MDX / a CMS / a database and produce the HTML these routes serve, and how does that interact with the two frameworks' rendering models?

Next chapter: MDX, Server Rendering, and Cloudflare Deploy — MDX in both frameworks (the format every modern docs site uses), Astro Islands vs Next.js Server Components vs Client Components vs Server Actions, and the exact deploy steps for both onto Cloudflare via @astrojs/cloudflare and OpenNext — with the bill at indie scale.

Ch 2: File-Based Routing + What `[slug]` MeansCh 4: MDX, Server Rendering & Cloudflare Deploy
WebUltimate Web Development SeriesWeb development tutorials for HTML, CSS, JavaScript, Next.js, Workers, databases, and production shipping.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access