IMG_0088.jpg

前言

你好,我是 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 發揮更大的威力。

這次我們要開發這樣一個技術部落格網站。

image.png

文章末尾也會說明「從零程式經驗到學會 Next.js 的學習路線圖」,敬請期待。

影片教材也有準備

我們也準備了更詳盡的教學影片。若文字教材有不清楚的細節,請參考影片內容。

本篇文章的適合對象

  • 想在最短時間內學會 Next.js
  • 想全面學習 Next.js 所需知識
  • 想以輸出為導向學習
  • 想紮實建立實力
  • 對 React 有基本認識

本文是給已經學過一次 React 的人。JSX、useState 等基本解說會省略;若你要從基本開始學,請先完成下列教學(此處原文指向外部教學)。

Next.js 是什麼?

Next.js 是一個全端(full-stack)框架,將在建立 React 應用時通常需要單獨安裝和設定的各種套件、工具整合並內建提供。

在傳統的 React 應用開發中,你可能需要額外加入這些東西:

  • 路由 → react-router
  • SEO 對策 → react-helmet
  • 打包工具 → Webpack、Vite 的設定
  • 伺服器端渲染 → Next.js 等框架
  • API 伺服器 → 另行建立 Express.js 等
  • 圖片最佳化 → 另外的套件或工具

使用 Next.js,以上許多功能要麼一開始就內建、要麼由官方優化提供,讓你不用為各式設定煩惱,就能快速開始開發!

此外,前端與後端(API Routes)可以在同一個專案中撰寫,因此被稱為「全端(Full-stack)」框架。

image.png

你可以在不改變 React 寫法的前提下,更有效率地建構出具生產環境品質的應用,換言之就是 React 的一個 All-in-one 套件。

我們會在開發中體驗並深入理解以下功能特性:

image.png

詳細會在教學中逐一說明,現在只需先有個整體的認識即可。

  1. Next.js 的環境建置

先來做環境建置。先確認是否已經安裝 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 若看到下面畫面即成功。

image.png

Next.js 預設已整合 TailwindCSS,所以最後的樣式會使用它。

  1. 設定路由

接著使用 App Router 來做路由。本次會建立以下頁面:

路徑 頁面名稱 頁面內容 頁面示意
/ 頂層頁面 顯示原創文章與 Qiita 文章 image.png
/qiita Qiita 文章列表 顯示 20 篇 Qiita 文章 image.png
/blogs 原創文章列表 顯示全部原創文章 image.png
/blogs/:id 原創文章詳細 顯示對應 ID 的文章詳細 image.png

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        # 全站共用版面配置

重要的檔案名包括:

  • page.tsx:定義該路由的主要內容,是必須的。若沒有此檔案,該路由無法存取。
  • layout.tsx:定義與子路由共用的版面配置。
  • [id] 這類以中括號包住的資料夾名稱則代表動態參數。

實際建立檔案來理解:

app/page.tsx

export default function Home() {
  return (
    <div>
      <h1>Topページ</h1>
    </div>
  )
}

打開 http://localhost:3000 可看到 TOP 已顯示。

image.png

接著建立 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 可以看到頁面。

image.png

同樣方法建立原創部落格列表:

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 可看到頁面。

image.png

最後是文章詳細頁,這是「動態頁面」。文章詳細頁會依 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。

  1. 用 SSR 建立首頁

首先建立首頁,我們會從 Qiita 與 MicroCMS 取得文章,並各顯示 4 篇。

Next.js 支援多種頁面渲染策略,本章我們採用「伺服器端渲染(SSR)」來實作首頁。

Next.js 的渲染策略說明

在實作首頁前,先介紹 Next.js 支援的各種渲染方式。

  1. 客戶端渲染(CSR)

image.png

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 則是「元件在哪裡執行」的概念,彼此獨立。

  1. 伺服器端渲染(SSR)

image.png

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 取得資料。

  1. 靜態網站生成(SSG)

image.png

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>
}
  1. 增量靜態重建(ISR)

image.png

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()
}
  1. 部分預渲染(PPR)

image.png

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>
  )
}

渲染方式的選擇
image.png

各種渲染方式各有適用場景:

  • CSR:適合需要根據使用者操作即時變化的 UI(儀表板、聊天室、管理介面),或 SEO 不重要的頁面(登入後的頁面)。
  • SSR:適合需要顯示最新資訊且 SEO 重要的頁面(新聞網站、SNS 時間軸、庫存資訊等)。
  • SSG:適合更新頻率低的靜態內容(公司簡介、使用條款、文件站),速度最快且伺服器負載低。
  • ISR:適合定期更新但不需即時性的內容(商品頁、天氣預報等),在速度與資料新鮮度間取得平衡。
  • PPR:當一個頁面內既有靜態內容又有動態內容時,例如文章正文靜態、留言動態,或商品資訊靜態、庫存數量動態。

本次首頁要從 Qiita 與 MicroCMS 取得文章並顯示,因此希望始終呈現最新文章,故採用 SSR。

顯示 Qiita 文章

首先從 Qiita 取得存取金鑰(Access Token)。
在 Qiita 網頁中點「ユーザー(使用者)」→「設定」。

image.png

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

image.png

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

image.png

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

image.png

把環境變數設定在 .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 帳號,請先註冊。進入服務管理畫面點「追加」,選擇「從頭建立」:

image.png

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

image.png

點選「ブログ」,將範例內容複製並新建一篇,修改標題為「テックブログ2」並發佈(按下公開,確認)。

重複此步驟建立「テックブログ3」「テックブログ4」。

接著取得 API 金鑰。在左側選單點「1個のAPIキー」,複製顯示的 API 金鑰。

image.png

將 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 管理畫面確認。

image.png

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

image.png

最後,為了後續重複使用,我們會把建立的型別(types)移到別的檔案中:

mkdir domain
touch domain/Article.ts

domain/Article.ts

export type QiitaResponse = {
  id: string;
  ... (此處原文後續內容中斷)
}

原文出處:https://qiita.com/Sicut_study/items/2c9df846e96a47900e6d


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝9   💬11   ❤️3
560
🥈
我愛JS
📝2   💬7   ❤️2
149
🥉
💬1  
4
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登