iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)系列 第 16

文章一多就要分頁與轉址,Astro 怎麼一次處理好?

  • 分享至 

  • xImage
  •  

先把三件事分開。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。

用 build 產物確認分頁數量

把 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 吃掉,文章頁與分頁都能各自生成。

舊網址搬家,用 redirect

假設網站原本把文章列表放在 /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()

要保留原網址,才用 rewrite

以下以 /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 301Location 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,「根路徑要不要導向預設語系」可以用 prefixDefaultLocaleredirectToDefaultLocale 設定,不必自己寫一頁 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


上一篇
同一個 [slug].astro,為什麼有時候 build 時就算好、有時候有人開才算?
下一篇
把前面學的拼成網站,Astro 專案骨架該先放什麼?
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言