這篇文章要來講講如何在RSC架構下實現Next.js Server Actions,完成註冊登入功能只要10分鐘!!

<aside> 💽

RSC 是Next.js近年(2026)最推薦的架構,不僅大幅提升了DX (Developer experience),在Browser render的效能也大幅提升了,甚至Next.js Server Actions能更加隱私的完成API的連接。

</aside>

專案架構

src/
└── app/
    └── auth/
        ├── page.tsx       (Client Component: 負責互動事件)
        └── actions.ts     (Server Actions: 負責 POST/DELETE/UPDATE)

MongoDB

資料庫如下:

Cluster0/
└── DB/ (DataBase)
    └── auth (Collection)

Page.tsx

這裡是頁面導出的地方,因為會有互動事件所以是”use Client”

image.png

image.png

"use client"

import React, { useActionState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { handleAuth } from "./actions"

export default function AuthPage() {
  const [state, formAction, isPending] = useActionState(handleAuth, null)
  const CommonFields = () => ( // 符合DRY原則
    <>
      <div className="space-y-2">
        <Label>Email</Label>
        <Input name="email" type="email" placeholder="[email protected]" required />
      </div>
      <div className="space-y-2">
        <Label>Password</Label>
        <Input name="password" type="password" required />
      </div>
    </>
  )
  return (
    <div className="flex min-h-screen items-center justify-center bg-background px-4">
      <div className="w-full max-w-md">
        <Card className="bg-card/90 backdrop-blur-sm shadow-sm border-border">
          <CardHeader className="space-y-2 text-center">
            <CardTitle className="text-xl font-semibold text-foreground">Welcome</CardTitle>
            <CardDescription className="text-sm text-muted-foreground">登入或註冊!</CardDescription>
          </CardHeader>

          <CardContent>
            {state?.error && <div className="p-2 mb-4 text-sm text-red-500 bg-red-50 rounded border border-red-200">{state.error}</div>}
            {state?.success && <div className="p-2 mb-4 text-sm text-green-500 bg-green-50 rounded border border-green-200">{state.success}</div>}

            <Tabs defaultValue="login" className="w-full">
              <TabsList className="grid w-full grid-cols-2 bg-muted/60">
                <TabsTrigger value="login">登入</TabsTrigger>
                <TabsTrigger value="register">註冊</TabsTrigger>
              </TabsList>

              {/* 登入表單 */}
              <TabsContent value="login">
                <form action={formAction} className="mt-6 space-y-4">
                  <input type="hidden" name="auth-type" value="login" />
                  <CommonFields/>
                  <Button className="w-full mt-2" disabled={isPending}>{isPending ? "處理中..." : "登入"}</Button>
                </form>
              </TabsContent>

              {/* 註冊表單 */}
              <TabsContent value="register">
                <form action={formAction} className="mt-6 space-y-4">
                  <input type="hidden" name="auth-type" value="register" />
                  <div className="space-y-2">
                    <Label htmlFor="name">Name</Label>
                    <Input id="name" name="name" placeholder="Jane Doe" required />
                  </div>
                  <div className="space-y-2">
                    <Label htmlFor="role">Role (身分)</Label>
                    <Select name="role" defaultValue="DEV">
                      <SelectTrigger>
                        <SelectValue placeholder="選擇你的角色" />
                      </SelectTrigger>
                      <SelectContent>
                        <SelectItem value="PO">Product Owner (PO)</SelectItem>
                        <SelectItem value="DEV">Developer (DEV)</SelectItem>
                        <SelectItem value="TL">Team Lead (TL)</SelectItem>
                      </SelectContent>
                    </Select>
                  </div>
                  <CommonFields/>
                  <Button className="w-full mt-2" disabled={isPending}>{isPending ? "建立帳號中..." : "建立帳號"}</Button>
                </form>
              </TabsContent>
            </Tabs>
            <p className="mt-6 text-center text-xs text-muted-foreground">
              @2026 Hy.Chen
            </p>
          </CardContent>
        </Card>
      </div>
    </div>
  )
}

useActionState

React 19推出的表單管理hook

https://zh-hans.react.dev/reference/react/useActionState

const [state, formAction, isPending] = useActionState(handleAuth, null)

變數名稱 名稱 做什麼用的?
state Action 狀態 這是後端回傳的結果(例如:{ error: "密碼錯誤" })。它讓你能在前端畫面顯示「註冊成功」或「報錯」。
formAction 表單動作 你要把它放在 <form action={formAction}>。當用戶點擊按鈕,它會自動把整份表單資料丟給後端。
isPending 處理狀態 一個 布林值 (true/false)。當資料正在傳輸時,它是 true。這讓你能在按鈕上顯示「轉圈圈」或變更文字。

傳統寫法:

const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);

shadcn/ui

複雜互動:Tabs & Select

Tabs (切換分頁)

Select (下拉選單)

shadcn/ui 的核心價值在於它遵循了 Composition Pattern (組合模式),這讓我們的 AuthPage 即使邏輯複雜,程式碼結構依然清晰可讀,且具備完整的無障礙支持。


當在 <form> 標籤上綁定了 action={formAction} 時,瀏覽器在表單送出的那一刻,會自動將這些資料封裝成一個標準的 FormData 物件給handleAuth。

actions.ts

事前作業

.env.local

# .env.local
MONGODB_URI=mongodb+srv://<username>:<password>@cluster.mongodb.net/<DataBase Name>

bcryptjs :一個專門為密碼雜湊Hashing設計的函式庫

npm install bcrypt
npm install --save-dev @types/bcrypt

mongodb

npm install mongodb
"use server"

import { MongoClient } from "mongodb"
import bcrypt from "bcrypt"
import { redirect } from "next/navigation"

const client = new MongoClient(process.env.MONGODB_URI!)

export async function handleAuth(prevState: any, formData: FormData) {
  const type = formData.get("auth-type") // 判斷是 login 還是 register
  const email = formData.get("email") as string
  const password = formData.get("password") as string
  
  await client.connect()
  const db = client.db("ESS-db")
  const collection = db.collection("user")

  if (type === "register") {
    const name = formData.get("name") as string
    const role = formData.get("role") as string
    
    // 檢查重複
    const existing = await collection.findOne({ email })
    if (existing) return { error: "該 Email 已被註冊" }

    // 加密並儲存
    const hashedPassword = await bcrypt.hash(password, 10)
    await collection.insertOne({
      name,
      email,
      password: hashedPassword,
      role,
      createdAt: new Date()
    })
    
    return { success: "註冊成功!請切換至登入分頁" }
  }

  if (type === "login") {
    // 錯誤攔截
    const user = await collection.findOne({ email })
    if (!user) return { error: "用戶不存在" }

    const isMatch = await bcrypt.compare(password, user.password)
    if (!isMatch) return { error: "密碼錯誤" }

    // 成功登入
    const userPayload = { id: user._id.toString(), role: user.role };
    (await cookies()).set("session", JSON.stringify(userPayload), { // 將登入資訊存入cookies
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    maxAge: 60 * 60 * 24 * 7, // 7 天
    path: "/",
    });
    redirect("/")
  }
}

Props

用Server Actions的寫法實現了最"整潔”的寫法:

  1. 零 API Route: 你不需要寫 /api/login
  2. 型別安全: 資料從表單到資料庫的流向清晰可見。
  3. 安全性: 所有敏感操作(加密、DB 連線)都隱藏在 "use server" 牆後。

cookies

如果登入成功,會將使用者的資訊寫進cookies中,為什麼不是寫進seesion呢?

有讀過瀏覽器與伺服器原理的應該就會知道,cookies用來存這種登入身分的驗證ˋ是最好的

https://medium.com/@bebebobohaha/cookie-localstorage-sessionstorage-差異-9e1d5df3dd7f