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 param | Query 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 time | Yes (generateStaticParams / getStaticPaths) | No — query strings aren't part of the file |
| Cached by CDN | Yes (each URL is its own cache key) | Usually treated as one URL — risky |
| SEO indexable | Yes — Google sees one URL per resource | Often deduplicated — many URLs collapse to one |
| Best for | Identity ("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/Aboutare 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:
| Code | Meaning | Use for |
|---|---|---|
| 301 Moved Permanently | Permanent — search engines update their index. | Renamed slug, retired page → its replacement. |
| 302 Found | Temporary — search engines keep the old URL indexed. | "Down for maintenance" / A/B test landing. |
| 307 Temporary | Like 302 but preserves the HTTP method (POST stays POST). | Modern temporary redirects, especially for API endpoints. |
| 308 Permanent | Like 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:
sitemap.xml— a machine-readable list of every canonical URL on the site, for search engines to crawl.robots.txt— instructions to crawlers about which paths to skip.
Both frameworks have first-class integrations:
- Astro:
npx astro add sitemap→ installs@astrojs/sitemap. Generatesdist/sitemap-index.xmlat build. - Next.js: create
app/sitemap.tsreturning an array of URLs; Next renders it. Same forapp/robots.ts.
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:
| Check | What right looks like |
|---|---|
Has a unique <title> | 50–60 chars, descriptive, includes the primary keyword once. |
| Has a unique meta description | 140–160 chars, summarises the page. |
| Has a canonical link | <link rel="canonical" href="https://example.com/…"> pointing at the production URL. |
| Has Open Graph tags | og: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.xml | Once, at the canonical URL. |
| Trailing slash matches site policy | Either always or never; redirects enforce. |
Mental Model — Three Sentences
- 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. - Every page that can be reached via more than one URL needs a
<link rel="canonical">pointing at its production URL — Astro builds it fromAstro.site; Next.js builds it frommetadata.alternates.canonical. - Pre-render dynamic routes with
generateStaticParams(Next.js) orgetStaticPaths(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)
- 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. - In an Astro project, set
site:inastro.config.mjsand 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. - Decide your trailing-slash policy. Set
trailingSlashin the config. Visit both/aboutand/about/and confirm one 301s to the other. - Add
app/sitemap.ts(Next.js) or install@astrojs/sitemap. Build, opendist/sitemap.xmlor visit/sitemap.xmlin dev mode. Submit it to Google Search Console. - 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.
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