
你好,我是 Watanabe jin。
看到 AI 發展讓很多人在用 React 做應用程式,我再次感受到這真的是個了不起的時代。
不過我個人覺得,讓 AI 把 React (寫得漂亮地)寫出來仍然有很多挑戰。當讓 AI 寫 React 時,常會把一切都想靠 useEffect 解決;而且 Next.js 在理解 client 與 server 的邊界上也不太擅長。
現在已經到了不用太多考慮就能做出簡單應用的時代,但若要做有一定規模的應用,除了 AI 外,工程師的技能仍然不可或缺。
我的結論是:
AI 無法輸出超過你自身能力的東西
這次會從 0 開始全面地解說 Next.js。例如若不知道 React 19 新增的「Server Action」,AI 就可能無法正確從 client 端呼叫 server 的 function,從而硬寫出不合適的程式碼。
為了更好地使用 AI,打好基礎非常重要。理解基礎並知道「什麼是好的程式碼」將有助於讓 AI 發揮更大的威力。
這次我們要開發這樣一個技術部落格網站。

文章末尾也會說明「從零程式經驗到學會 Next.js 的學習路線圖」,敬請期待。
我們也準備了更詳盡的教學影片。若文字教材有不清楚的細節,請參考影片內容。
本文是給已經學過一次 React 的人。JSX、useState 等基本解說會省略;若你要從基本開始學,請先完成下列教學(此處原文指向外部教學)。
Next.js 是一個全端(full-stack)框架,將在建立 React 應用時通常需要單獨安裝和設定的各種套件、工具整合並內建提供。
在傳統的 React 應用開發中,你可能需要額外加入這些東西:
使用 Next.js,以上許多功能要麼一開始就內建、要麼由官方優化提供,讓你不用為各式設定煩惱,就能快速開始開發!
此外,前端與後端(API Routes)可以在同一個專案中撰寫,因此被稱為「全端(Full-stack)」框架。

你可以在不改變 React 寫法的前提下,更有效率地建構出具生產環境品質的應用,換言之就是 React 的一個 All-in-one 套件。
我們會在開發中體驗並深入理解以下功能特性:

詳細會在教學中逐一說明,現在只需先有個整體的認識即可。
先來做環境建置。先確認是否已經安裝 Node.js。若遇到錯誤請自行安裝 Node.js。
node -v
v24.1.0
可以用官方文件提供的指令快速建立 Next.js 專案:
npx create-next-app@latest tech-blog --yes
Need to install the following packages:
[email protected]
Ok to proceed? (y) y
注意:關於版本
本教學使用
[email protected](Next.js 16)。
範例程式與設定均假設使用 Next.js 16。若使用 Next.js 15,請注意後述設定差異。
建立專案後,試著啟動伺服器:
cd tech-blog
npm run dev
打開 http://localhost:3000 若看到下面畫面即成功。

Next.js 預設已整合 TailwindCSS,所以最後的樣式會使用它。
接著使用 App Router 來做路由。本次會建立以下頁面:
| 路徑 | 頁面名稱 | 頁面內容 | 頁面示意 |
|---|---|---|---|
| / | 頂層頁面 | 顯示原創文章與 Qiita 文章 | ![]() |
| /qiita | Qiita 文章列表 | 顯示 20 篇 Qiita 文章 | ![]() |
| /blogs | 原創文章列表 | 顯示全部原創文章 | ![]() |
| /blogs/:id | 原創文章詳細 | 顯示對應 ID 的文章詳細 | ![]() |
App Router 是從 Next.js 13 起導入的新路由系統。相較於舊的 Pages Router,保留了以檔案為基礎的路由概念,同時加入更多彈性與強大的功能。
App Router 最重要的特性是:app 目錄內的資料夾結構會直接對應到 URL 路徑。此外預設使用 Server Component(伺服器元件),因此能更容易構建在效能與 SEO 上表現良好的應用。也方便管理多頁面共用的 Layout。
在 App Router 中,app 目錄的資料夾結構非常重要。資料夾名稱會對應 URL 路徑,特定檔名則決定該路由的角色:
app/
├── page.tsx # / (首頁)
├── qiita/
│ └── page.tsx # /qiita
├── blogs/
│ ├── page.tsx # /blogs
│ └── [id]/
│ └── page.tsx # /blogs/:id (動態路由)
└── layout.tsx # 全站共用版面配置
重要的檔案名包括:
實際建立檔案來理解:
app/page.tsx
export default function Home() {
return (
<div>
<h1>Topページ</h1>
</div>
)
}
打開 http://localhost:3000 可看到 TOP 已顯示。

接著建立 Qiita 列表頁面:
mkdir app/qiita
touch app/qiita/page.tsx
app/qiita/page.tsx
export default function Qiita() {
return (
<div>
<h1>Qiitaページ</h1>
</div>
)
}
開啟 http://localhost:3000/qiita 可以看到頁面。

同樣方法建立原創部落格列表:
mkdir app/blogs
touch app/blogs/page.tsx
app/blogs/page.tsx
export default function Blogs() {
return (
<div>
<h1>Blogsページ</h1>
</div>
)
}
開啟 http://localhost:3000/blogs 可看到頁面。

最後是文章詳細頁,這是「動態頁面」。文章詳細頁會依 URL 中的文章 ID 顯示不同內容,例如 /blogs/1、/blogs/2。要實現這種動態路徑,Next.js 使用中括號做特殊表示。
在 app/blogs 資料夾內建立名為 [id] 的資料夾,並在其內新增 page.tsx:
mkdir app/blogs/[id]
touch app/blogs/[id]/page.tsx
app/blogs/[id]/page.tsx
async function BlogContent({ params }: { params: { id: string } }) {
const { id } = params;
return (
<div>
<h1>BlogDetailページ</h1>
<p>ID: {id}</p>
</div>
);
}
export default function BlogDetail({ params }: { params: { id: string } }) {
return <BlogContent params={params} />;
}
資料夾名稱 [id] 就表示這是一個動態參數。當使用者存取 /blogs/123 時,params 會自動包含 { id: "123" }。
接著說明程式碼要點。首先,注意元件標示為 async:
async function BlogContent({ params }: { params: Promise<{ id: string }> }) { ... }
這是 Server Component 的一大特性:可以在元件內直接執行非同步處理。
什麼是 Server Component?
Server Component(伺服器元件)是在伺服器端執行,並以已經完成的 HTML 傳回瀏覽器的 React 元件。
在 RSC(React Server Components)出現前(例如 Create React App),所有元件都在瀏覽器執行。伺服器回傳幾乎空的 HTML,下載並執行 JS 後才組裝頁面,這就是 CSR(客戶端渲染)。而 Server Component 只在伺服器執行,因此不能使用 useState 或 useEffect 等瀏覽器專用 API,但能在伺服器端完成資料取得再生成 HTML,提升首屏速度並減小 JS 包體積。
另外,若元件加上 "use client" 變為 Client Component,常會與 SSR/SSG 搭配使用,因此「Client Component = CSR」並不正確。CSR、SSR、SSG 是渲染策略(哪裡產生 HTML),而 Server/Client Component 表示元件執行位置,兩者是不同的概念。
再來注意 params 為 Promise 的部分:
const { id } = await params;
在 Next.js 15 以後,動態路由的參數需要以非同步方式取得,因此以 await params 來接收,然後以解構取得 id。
首先建立首頁,我們會從 Qiita 與 MicroCMS 取得文章,並各顯示 4 篇。
Next.js 支援多種頁面渲染策略,本章我們採用「伺服器端渲染(SSR)」來實作首頁。
在實作首頁前,先介紹 Next.js 支援的各種渲染方式。

CSR 是傳統 React 應用常用的方式。伺服器只回傳最小的 HTML,瀏覽器下載並執行 JavaScript 才會把頁面內容組裝起來。
此方式在初次載入時,使用者會先拿到幾乎空的 HTML,之後才下載包含 React 的 JavaScript 並在瀏覽器端執行。TypeScript 代碼會在建置時轉成 JavaScript,最後以 HTML 與 JS 檔案發佈。
CSR 的優點是「首次下載後的頁面切換非常快速」。一旦下載完 JavaScript,跳頁時無需向伺服器取得新的完整 HTML,只需從 API 取得必要資料即可更新頁面。對於需要依使用者互動動態變化的 UI(例如儀表板、聊天室)特別適合。
但缺點是初次渲染較慢,因為需要等待 JavaScript 下載與執行;對 SEO 也不利,因為搜尋引擎爬蟲拿到的 HTML 通常是空的。
在 Next.js 中若要使用 CSR,需要在元件最上方加上 "use client" 指示:
"use client"
import { useState, useEffect } from 'react'
export default function ClientPage() {
const [data, setData] = useState(null)
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(setData)
}, [])
return <div>{data ? data.title : 'Loading...'}</div>
}
注意:即使加上 "use client",Client Component 並不等同於完整的 CSR。CSR/SSR/SSG 是「在哪裡產生 HTML」的策略;Server/Client Component 則是「元件在哪裡執行」的概念,彼此獨立。

SSR 是每次使用者存取頁面時,在伺服器端產生 HTML 再傳回瀏覽器。每次存取都會在伺服器端呼叫 API 取得資料,然後用該資料生成完整 HTML。
SSR 的最大優點是「用戶在瀏覽器拿到的就是完整的 HTML」。使用者可以立即看到內容,搜尋引擎爬蟲也能讀取完整 HTML,對 SEO 有利。且可以保證內容是最新的,適合頻繁更新的內容。
缺點是每次存取都會有伺服器端處理,若 API 呼叫耗時,整體頁面加載也會變慢,伺服器負載也會增加。
範例:
async function getArticles() {
const res = await fetch('https://api.example.com/articles', {
cache: 'no-store' // 不使用快取,總是取得最新資料
})
return res.json()
}
export default async function ArticlesPage() {
const articles = await getArticles()
return (
<div>
<h1>記事一覧</h1>
{articles.map(article => (
<div key={article.id}>{article.title}</div>
))}
</div>
)
}
在此例中,元件本身定義為 async,並在其中直接取得資料。傳統 React 常在 useEffect 中做資料抓取,但 Server Component 無法使用 useEffect,因此直接把元件定義為非同步函式並在內部 await 取得資料。

SSG 是在建置時(build time)一次生成 HTML,之後就直接提供靜態檔案的方式。建置時會從 API 取得資料生成 HTML;不論多少次存取,伺服器都只回傳事先生成的 HTML,不需再次呼叫 API。
優點是速度極快,因為直接提供已完成的 HTML,伺服器處理時間幾乎為零,與 CDN 搭配能全球快速提供內容。缺點是內容更新須重新建置並部署,故不適合頻繁更新的內容。
在 App Router 中,可以在 fetch 中指定 cache: 'force-cache' 來實現 SSG:
async function getDocs() {
const res = await fetch('https://api.example.com/docs', {
cache: 'force-cache' // 在建置時被快取
})
return res.json()
}
對於動態路由(如 [id])使用 SSG 時,可透過 generateStaticParams 指定要在建置時生成的頁面:
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json())
return posts.map((post: { id: string }) => ({
id: post.id
}))
}
export default async function BlogPost({ params }: { params: { id: string } }) {
const { id } = params
const post = await fetch(`https://api.example.com/posts/${id}`)
.then(res => res.json())
return <article>{post.content}</article>
}

ISR 結合了 SSG 與 SSR 的優點。和 SSG 一樣會在建置時生成 HTML,但可設定一段時間後讓系統在背景中重新生成 HTML(例如每隔 60 秒)。在第一次的存取會回傳建置時的 HTML,之後的 60 秒內仍會使用相同 HTML;超過 60 秒後的下一次存取會在背景觸發新 HTML 的生成。
範例:
async function getNews() {
const res = await fetch('https://api.example.com/news', {
next: { revalidate: 60 } // 每 60 秒重新驗證
})
return res.json()
}

PPR(Partial Pre-rendering)是從 Next.js 14 起引入的新功能,允許在同一頁面中結合靜態與動態部分。將頁面內容拆成靜態與動態兩部分:靜態部分在初次存取即顯示,而動態部分則可採用串流(streaming)逐步顯示,讓頁面在載入時就先呈現可見內容。
在 Next.js 中使用 PPR 常與 Suspense 搭配:
import { Suspense } from 'react'
async function Comments() {
const comments = await fetch('https://api.example.com/latest-comments', {
cache: 'no-store'
}).then(res => res.json())
return (
<ul>
{comments.map((c: any) => <li key={c.id}>{c.text}</li>)}
</ul>
)
}
export default function Page() {
return (
<main>
{/* 靜態部分:建置時生成,使用者打開時立即可見 */}
<h1>最新的ユーザーコメント</h1>
<p>ここは静的シェルとして、一瞬で画面に表示されます。</p>
<hr />
{/* 動態部分:在靜態部分顯示後,背景抓取資料並完成後顯示 */}
<Suspense fallback={<div>コメントを読み込み中...</div>}>
<Comments />
</Suspense>
</main>
)
}
渲染方式的選擇

各種渲染方式各有適用場景:
本次首頁要從 Qiita 與 MicroCMS 取得文章並顯示,因此希望始終呈現最新文章,故採用 SSR。
首先從 Qiita 取得存取金鑰(Access Token)。
在 Qiita 網頁中點「ユーザー(使用者)」→「設定」。

左側選單「アプリケーション(應用程式)」→「新しいトークンを発行する(發行新 Token)」。

在 Access Token 的說明欄輸入「テックブログアプリ」,點「發行」。

將發行出的 Access Token 複製到剪貼簿。

把環境變數設定在 .env:
touch .env
.env
QIITA_API_KEY=你剛剛複製的 Token
存取金鑰屬於祕密資訊,請確保不會推到 GitHub。create-next-app 生成的 .gitignore 已包含 .env*,一般來說不需額外處理,但為保險起見可以確認或用 >> 追加到 .gitignore(注意不要覆寫)。
echo .env >> .gitignore
接著實際取得文章:
npm i axios
在 app/page.tsx 範例:
import axios from "axios";
type QiitaResponse = {
id: string;
title: string;
url: string;
image: string;
}
export default async function Home() {
const getQiitaItems = async () => {
const response = await axios.get<QiitaResponse[]>(
"https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=4",
{
headers: {
"Authorization": `Bearer ${process.env.QIITA_API_KEY}`
}
}
);
return response.data;
}
const qiitaItems = await getQiitaItems();
return (
<div>
<h1>Topページ</h1>
<ul>
{qiitaItems.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
)
}
若成功抓到 4 篇文章就表示設定正確。
我們來詳解實作:先定義 Qiita API 回傳的型別(TypeScript):
type QiitaResponse = {
id: string;
title: string;
url: string;
image: string;
}
明確定義型別可以提升程式的安全性與開發時偵錯能力。實際的 Qiita API 回應會包含更多欄位,但此處只定義我們會用到的項目。
接著是 Server Component 的非同步處理範例:
export default async function Home() {
}
元件加上 async,可以在元件內直接使用 await 抓取資料,無需像傳統 React 在 useEffect 中處理。
以下示範使用 axios 並從 process.env.QIITA_API_KEY 取得金鑰。在 Next.js 中 .env 的值會自動讀入 process.env。
提醒:實務上請加上錯誤處理(try/catch),此教學為了簡潔省略。
顯示 Qiita 的 OGP 圖片
由於 Qiita 未提供方便的 OGP 圖片取得機制,此處使用固定圖示。
app/page.tsx(含 Image 引入)
import axios from "axios";
import Image from "next/image";
type QiitaResponse = {
id: string;
title: string;
url: string;
image: string;
}
export default async function Home() {
const getQiitaItems = async () => {
const response = await axios.get<QiitaResponse[]>(
"https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=4",
{
headers: {
"Authorization": `Bearer ${process.env.QIITA_API_KEY}`
}
}
);
return response.data.map((item) => ({
id: item.id,
title: item.title,
url: item.url,
image: "https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F810513%2F04c6ef92-7b08-467f-95b0-efd05a0e7ea4.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&w=1400&fit=max&s=255a4084e07534dc5871b77aa1318d0e"
}));
}
const qiitaItems = await getQiitaItems();
return (
<div>
<h1>Topページ</h1>
<ul>
{qiitaItems.map((item) => (
<li key={item.id}>
<Image src={item.image} alt={item.title} width={100} height={100} />
<a href={item.url} target="_blank" rel="noopener noreferrer">{item.title}</a>
</li>
))}
</ul>
</div>
)
}
Next.js 提供的 Image 元件可取代 HTML 的 <img>,自動做圖片最佳化,例如自動轉 WebP、預設延遲載入(lazy loading)、避免 layout shift 等。Image 元件需要指定 src、alt、width 與 height。
當你在畫面上渲染外部圖片時,可能會看到錯誤,因為 Next.js 預設會限制允許載入的外部圖片網域。需要在 next.config.ts 中設定允許的遠端來源。
next.config.ts
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'qiita-user-contents.imgix.net',
},
{
protocol: 'https',
hostname: 'images.microcms-assets.io',
},
],
},
};
因為之後也會用到 MicroCMS 圖片,所以一併新增其網域設定。修改完設定後需重新啟動開發伺服器:
// 先停止 dev,然後重新啟動
npm run dev
畫面就能正常顯示圖片了。
接著以相同方式顯示 MicroCMS 的文章。
若你還沒有 MicroCMS 帳號,請先註冊。進入服務管理畫面點「追加」,選擇「從頭建立」:

輸入服務名稱(例如「テックブログアプリ」)並建立服務:

點選「ブログ」,將範例內容複製並新建一篇,修改標題為「テックブログ2」並發佈(按下公開,確認)。
重複此步驟建立「テックブログ3」「テックブログ4」。
接著取得 API 金鑰。在左側選單點「1個のAPIキー」,複製顯示的 API 金鑰。

將 MicroCMS 的 API 金鑰加入 .env:
.env
QIITA_API_KEY=xxxxxxxx
MICROCMS_API_KEY=你剛剛複製的 API 金鑰
依照 MicroCMS 文件呼叫 API 取得文章列表:
app/page.tsx
import axios from "axios";
import Image from "next/image";
type QiitaResponse = {
id: string;
title: string;
url: string;
image: string;
}
type MicrocmsContent = {
id: string;
title: string;
eyecatch: {
url: string;
};
};
type MicrocmsResponse = {
contents: MicrocmsContent[];
};
export default async function Home() {
const getQiitaItems = async () => {
const response = await axios.get<QiitaResponse[]>(
"https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=4",
{
headers: {
"Authorization": `Bearer ${process.env.QIITA_API_KEY}`
}
}
);
return response.data.map((item) => ({
id: item.id,
title: item.title,
url: item.url,
image: "https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F810513%2F04c6ef92-7b08-467f-95b0-efd05a0e7ea4.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&w=1400&fit=max&s=255a4084e07534dc5871b77aa1318d0e"
}));
}
const getMicrocmsItems = async () => {
const response = await axios.get<MicrocmsResponse>(
"https://[你的域名].microcms.io/api/v1/blogs",
{
headers: {
"X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`
}
}
);
return response.data.contents.map((item) => ({
id: item.id,
title: item.title,
url: `/blogs/${item.id}`,
image: item.eyecatch.url
}));
}
const qiitaItems = await getQiitaItems();
const microcmsItems = await getMicrocmsItems();
return (
<div>
<h1>Topページ</h1>
<ul>
{qiitaItems.map((item) => (
<li key={item.id}>
<Image src={item.image} alt={item.title} width={100} height={100} />
<a href={item.url} target="_blank" rel="noopener noreferrer">{item.title}</a>
</li>
))}
</ul>
<ul>
{microcmsItems.map((item) => (
<li key={item.id}>
<Image src={item.image} alt={item.title} width={100} height={100} />
<a href={item.url} target="_blank" rel="noopener noreferrer">{item.title}</a>
</li>
))}
</ul>
</div>
)
}
注意:上面 MicroCMS 的 endpoint(https://[你的域名].microcms.io/api/v1/blogs)需改成你自己的 service ID,可以在 MicroCMS 管理畫面確認。

顯示效果良好。透過瀏覽器的 Network 分頁可以看到伺服器回傳的是伺服器端處理後產生的 HTML。

最後,為了後續重複使用,我們會把建立的型別(types)移到別的檔案中:
mkdir domain
touch domain/Article.ts
domain/Article.ts
export type QiitaResponse = {
id: string;
... (此處原文後續內容中斷)
}
原文出處:https://qiita.com/Sicut_study/items/2c9df846e96a47900e6d