iT邦幫忙

0

用 Next.js Server Actions 10 分鐘實作完備的登入註冊系統

  • 分享至 

  • xImage
  •  

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

原網址:Notion

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

專案架構

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

MongoDB

資料庫如下:

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

Page.tsx

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

  • CommonFields : 程式碼重用性 DRY (Don't Repeat Yourself)
"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="you@example.com" 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 (切換分頁)

  • TabsList: 分頁的按鈕容器。
  • TabsTrigger: 個別的切換按鈕。
  • TabsContent: 對應內容。
  • 原理: 它處理了鍵盤導覽(上下左右鍵切換分頁)與 ARIA 角色分配,讓你的登入/註冊切換符合 Web 標準。

Select (下拉選單)

  • SelectTrigger & SelectValue: 負責顯示目前的選擇。
  • SelectContent & SelectItem: 負責彈出的選單清單。
  • 優勢: 解決了原生 <select> 在不同瀏覽器(Chrome vs Safari)樣式難以統一的痛點,同時支援漂亮的動畫效果。

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

actions.ts

事前作業

.env.local

# .env.local
MONGODB_URI=mongodb+srv://<username>:<password>@cluster.mongodb.net/Agile-ESS?retryWrites=true&w=majority

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: "密碼錯誤" }

    // 這裡通常會設定 Cookie/Session,目前先模擬成功導向
    redirect("/dashboard")
  }
}

Props

  • prevState: 因為我們前端使用了 useActionState,所以第一個參數必須是「上一次的狀態」。雖然這裡沒用到,但位置必須留著。
  • formData.get(...): 這是原生 Web API。Server Actions 最優雅的地方在於它直接處理 FormData 物件,你不需要像以前一樣寫 req.body.email
  • as string: 強制轉型,因為 get 拿到的可能是檔案或字串,我們明確告知這是文字。
  • 連線三部曲: connect() (連接) -> db() (選擇資料庫) -> collection() (選擇資料表)。

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

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

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言