先把三件事分開。paginate() 把同一批資料切成多個 build-time 頁面;redirect 把讀者從舊網址送到新網址,地址列會跟著改;rewrite 保留原網址,但改由另一個 route 回傳內容。
Day 15:動態路由的 SSG/SSR 分界把 getStaticPaths() 比成一份 build 前就要列好的名冊。paginate() 沿用這份名冊,在文章變多時把它切成數頁;網址改版時,再替舊入口選擇 redirect 或 rewrite。
Day 14:文章列表與搜尋 island已經做了一個即時搜尋頁。搜尋和分頁各自回答不同問題:
這個 capstone 因此同時保留 /blog 的搜尋 island,以及 /blog/page/1、/blog/page/2 這組靜態分頁。
Day 3:檔案式路由提過,src/pages 的資料夾結構會直接變成網址。分頁 route 放在:
src/pages/blog/page/[page].astro
它會產生:
/blog/page/1
/blog/page/2
/blog/page/3
...
多一層 page/ 能隔開頁碼與文章 slug。專案原本已有 blog/[...slug].astro 負責 /blog/day-16-* 這類文章網址。如果分頁也寫成 blog/[page].astro,所有單層 /blog/* 都會共用一個命名空間。改用 /blog/page/[page] 後,頁碼與文章 slug 分開,路由也成為巢狀結構。
paginate() 還是在 build 時工作分頁 route 的 frontmatter 如下:
---
import type { CollectionEntry } from 'astro:content';
import type { GetStaticPaths, Page } from 'astro';
import { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
export const prerender = true;
export const getStaticPaths = (async ({ paginate }) => {
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
(a, b) => a.data.day - b.data.day,
);
return paginate(posts, { pageSize: 6 });
}) satisfies GetStaticPaths;
interface Props {
page: Page<CollectionEntry<'blog'>>;
}
const { page } = Astro.props;
---
資料仍由 Day 10:Content Collections用過的 getCollection() 取得。這裡先濾掉草稿、依 Day 排序,再把完整陣列交給 paginate(),每頁放 6 篇。
paginate() 是 getStaticPaths() 參數裡的 helper。它依資料筆數與 pageSize 建立所有頁碼,回傳格式也符合 getStaticPaths() 的要求。Astro 在 build 階段就把第 1 頁到最後一頁全部算好,不會等讀者打開第 2 頁才切資料。
paginate() 只處理 SSG 分頁。若要做 /search?page=2,而資料會依 query、登入者或資料庫當下內容改變,就要在 on-demand route 自己讀 Astro.url.searchParams,再對資料來源做 limit/offset 或 cursor 查詢。build-time 的 paginate() 不能拿來做 request-time 資料庫分頁。
page 幫你算模板拿到的 page 不只包含當頁文章:
| 欄位 | 內容 |
|---|---|
page.data |
這一頁的資料 |
page.total |
全部資料筆數 |
page.currentPage / page.lastPage |
目前頁碼/最後頁碼 |
page.start / page.end |
當頁第一筆/最後一筆在完整資料中的位置,從 0 起算 |
page.url.current |
目前頁網址 |
page.url.prev / page.url.next |
上一頁/下一頁網址,沒有時是 undefined |
page.url.first / page.url.last |
第 1 頁/最後一頁網址,需要時才有值 |
Astro 7.1 另外替 paginate() 加了 format(url),可把 page.url.* 轉成帶 .html 的網址,給不支援 clean URL 的靜態主機使用。它只改導覽 URL,不會改 params.page。Cloudflare 支援 clean URL,這個專案不需要設定。
列表與導覽可以直接照這些資料 render:
<ol start={page.start + 1}>
{
page.data.map((post) => (
<li>
<a href={`/blog/${post.id}`}>
Day {post.data.day}:{post.data.title}
</a>
<p>{post.data.description}</p>
</li>
))
}
</ol>
<nav aria-label="文章分頁">
{page.url.first && <a href={page.url.first}>第 1 頁</a>}
{page.url.prev && <a href={page.url.prev}>上一頁</a>}
<span aria-current="page">
第 {page.currentPage} 頁,共 {page.lastPage} 頁
</span>
{page.url.next && <a href={page.url.next}>下一頁</a>}
{page.url.last && <a href={page.url.last}>第 {page.lastPage} 頁</a>}
</nav>
不要自己用目前頁碼加減後拼 URL。第 1 頁沒有上一頁、最後一頁沒有下一頁,這些邊界 page.url.* 已經處理好。模板只在值存在時輸出連結即可。
這個分頁以普通連結與靜態 HTML 運作。查資料、切頁與產生導覽都發生在 build 時,不需要 island。
把 Day 16 本身加入 collection 後,專案共有 19 篇發布文章。設定每頁 6 篇,npm run build 的 prerender 清單實際出現:
/blog/page/1/index.html
/blog/page/2/index.html
/blog/page/3/index.html
/blog/page/4/index.html
前 3 頁各有 6 篇,第 4 頁有 1 篇。4 個頁面都落在 dist/client/blog/page/,不是 dist/server/。這跟 Day 20:靜態 endpoint 與 RSS看到的分界一致:本專案預設 output: 'static',沒有即時資料需求的 route 會在 build 時產成檔案。
build 產物也確認 /blog/page/[page] 沒有被既有的 blog/[...slug].astro 吃掉,文章頁與分頁都能各自生成。
假設網站原本把文章列表放在 /articles,現在改到 /blog/page/1。這是「舊網址已搬家」,應放進 astro.config.mjs 的全站 redirect mapping:
export default defineConfig({
redirects: {
'/articles': '/blog/page/1',
},
});
在目前的 @astrojs/cloudflare static adapter 下,build 會生成 dist/client/_redirects:
/articles/ /blog/page/1 301
/articles /blog/page/1 301
dev server 的實際 response 也相同:
HTTP/1.1 301 Moved Permanently
Location: /blog/page/1
瀏覽器收到 301 後會再請求新網址,地址列最後顯示 /blog/page/1。搜尋引擎與使用者都知道內容已經搬家。
固定、全站適用的舊新網址 mapping 放在 config 的 redirects。若跳轉條件要等某個 route 執行時才知道,例如查不到文章便前往 404,才在頁面裡用 Astro.redirect()。
以下以 /demos/rewrite-blog 實測 rewrite。
---
export const prerender = true;
return Astro.rewrite('/blog/page/1');
---
開啟 /demos/rewrite-blog 時,Astro 改由 /blog/page/1 的 route 產生內容,但 response 是 200 OK,沒有 Location header,瀏覽器網址仍留在 /demos/rewrite-blog。
| 行為 | redirect | rewrite |
|---|---|---|
| 地址列 | 變成目標網址 | 保留原網址 |
| 本次實測 response | 301 + Location |
200,沒有 Location |
| 內容怎麼來 | 瀏覽器再請求目標網址 | Astro 直接用目標 route 回內容 |
| 適合情境 | 舊網址永久搬家 | URL 要保留,但內容交給另一個 route |
rewrite 讓同一份內容能從不同網址取得,使用不當會製造重複 URL。這個 demo 的 canonical 指回 /blog/page/1。
rewrite 後,Astro.url 代表目前拿來 render 的目標網址;若程式需要知道使用者原本請求哪一條路徑,可以讀 Astro.originPathname。這是做條件式 rewrite 或除錯時才需要的細節,一般分頁不必碰它。
一個上線中的多語系品牌官網同時使用這兩個機制,各自處理不同入口。四個語系分別放在 /en、/cn、/es、/pt,根路徑交給 rewrite:
---
// src/pages/index.astro
import { defaultLocale } from '@/i18n';
return Astro.rewrite(`/${defaultLocale}`);
---
同一個專案的 config 裡另有一條 redirect:
redirects: { '/index': '/' },
在專案副本執行 build 後,可以分開看到兩者的產物:rewrite 讓 dist/index.html 直接帶著預設語系的內容,網址仍是 /;redirect 另外產出 dist/index/index.html 這個轉址頁,把 /index 送回 /。/ 是要保留的正式網址,/index 是不該再存在的舊入口。
如果多語系路由使用 Astro 的 i18n config,「根路徑要不要導向預設語系」可以用 prefixDefaultLocale 與 redirectToDefaultLocale 設定,不必自己寫一頁 rewrite。這個專案沒有使用 i18n config,因此用 rewrite 完成同一件事(Day 26 會接這條線)。
分頁、搜尋與無限滾動不在同一層。分頁提供穩定、可分享的瀏覽位置;搜尋縮小目標;無限滾動只是列表的載入方式。
Astro 的 redirects 也不適合用來處理靜態頁 trailing slash。預渲染頁的尾斜線行為可能由 hosting platform 處理,應查部署平台規則。
本文以 Astro 7.1.1、@astrojs/cloudflare 14.1.3,於 2026-07-23 實測。paginate() 的欄位與現行範例可查 Astro routing reference,redirect 設定查 configuration reference,rewrite 行為查 API context reference。這些 API 與 adapter 輸出都屬易變知識,升級後應重新核對。
Day 17 會把目前分散的列表、文章與 demo 收進可展示的網站骨架,補上 nav、hero、cards、404 與 footer。
本日程式碼:step-16|只看這天的改動:step-15...step-16