🔥【Vue.js → Nuxt 入門推薦!🌟 新書即將上市 🌟】
📘《想要 SSR 嗎?就使用 Nuxt 吧!Nuxt 讓 Vue.js 更好處理 SEO 搜尋引擎最佳化》
👀 Nuxt v4 內容與範例也可以參考並購買本系列文筆著所著書籍
📦 預計於 2025/08/14 出版,目前天瓏書局預購有 7️⃣8️⃣ 折優惠
👉 點此前往購買:https://pse.is/7yulwm
注意:Nuxt 4 已於 2025/07/16 釋出,本文部分內容或範例可能和最新版本有所不同
👆建議你可以使用影片子母畫面功能或全螢幕播放來獲得最佳的觀賞體驗,👇下方是本篇教學的相關筆記。
在 layouts 目錄下建立一個檔案 ./layouts/default.vue:
<template>
  <div>
    <slot />
  </div>
</template>
在 pages 目錄下建立一個檔案 ./pages/index.vue:
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-3xl font-semibold text-gray-700">
        這裡是
        <a href="https://ithelp.ithome.com.tw/users/20152617/ironman/6964" target="_blank">
          Nuxt 3 快速入門
        </a>
        實戰部落格
      </h1>
    </div>
  </div>
</template>
修改 app.vue 檔案:
 <template>
   <div>
    <NuxtPage />
   </div>
 </template>
安裝 NuxtIcon 套件
npm install -D nuxt-icon
修改專案目錄下 nuxt.config.ts 檔案,添加使用 nuxt-icon 至 modules 選項
export default defineNuxtConfig({
  modules: ['nuxt-icon']
})
在 components 目錄下建立一個檔案 ./components/LayoutHeader.vue:
<template>
  <header class="flex w-full justify-center">
    <nav class="flex w-full max-w-7xl items-center justify-between px-6 py-2">
      <div>
        <a href="/">
          <div class="flex items-center justify-between">
            <div class="mr-3">
              <Icon class="h-12 w-12" name="logos:nuxt-icon" />
            </div>
            <div class="hidden h-6 text-2xl font-semibold text-gray-700 sm:block">Nuxt 3 Blog</div>
          </div>
        </a>
      </div>
      <NuxtLink
        class="px-3 py-2 text-gray-700 transition hover:text-emerald-500"
        to="/login"
      >
        登入
      </NuxtLink>
    </nav>
  </header>
</template>
調整預設布局模板 ./pages/index.vue 檔案,添加使用 元件:
<template>
  <div>
    <LayoutHeader />
    <slot />
  </div>
</template>
在 pages 目錄下建立一個檔案 ./pages/login.vue:
<template>
  <div class="flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
    <div class="w-full max-w-md">
      <div class="flex flex-col items-center">
        <NuxtLink to="/">
          <Icon name="logos:nuxt-icon" size="80" />
        </NuxtLink>
        <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-700">登入帳號</h2>
      </div>
      <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
        <div class="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
          <form class="space-y-6">
            <div>
              <label for="account" class="block text-sm font-medium text-gray-700">帳號</label>
              <div class="mt-1">
                <input
                  id="account"
                  name="account"
                  type="text"
                  autocomplete="account"
                  required
                  class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
                />
              </div>
            </div>
            <div>
              <label for="password" class="block text-sm font-medium text-gray-700">密碼</label>
              <div class="mt-1">
                <input
                  id="password"
                  name="password"
                  type="password"
                  autocomplete="current-password"
                  required
                  class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
                />
              </div>
            </div>
            <div>
              <button
                type="submit"
                class="flex w-full justify-center rounded-md border border-transparent bg-emerald-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
              >
                登入
              </button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>
調整預設登入頁面 ./pages/login.vue 檔案,添加 definePageMeta 函式來禁止使用布局模板:
<script setup>
definePageMeta({
  layout: false
})
</script>
安裝 jsonwebtoken 套件
npm install -D jsonwebtoken
在 server/api 目錄下建立一個檔案 ./server/api/login.post.js:
import jwt from 'jsonwebtoken'
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  if (!(body.account === 'ryan' && body.password === 'iThome2023')) {
    throw createError({
      statusCode: 400,
      statusMessage: '登入失敗'
    })
  }
  const jwtTokenPayload = {
    id: 1,
    nickname: 'Ryan',
    email: 'ryanchien8125@gmail.com',
    avatar: 'https://images.unsplash.com/photo-1577023311546-cdc07a8454d9?fit=crop&w=128&h=128'
  }
  const maxAge = 60 * 60 * 24 * 7
  const expires = Math.floor(Date.now() / 1000) + maxAge
  const jwtToken = jwt.sign(
    {
      exp: expires,
      data: jwtTokenPayload
    },
    'JWT_SIGN_SECRET_PLEASE_REPLACE_WITH_YOUR_KEY'
  )
  setCookie(event, 'access_token', jwtToken, {
    maxAge,
    expires: new Date(expires * 1000),
    secure: true,
    httpOnly: true,
    path: '/'
  })
  return '登入成功'
})
調整登入頁面 ./pages/login.vue 檔案,添加建立 loginData 響應式物件並綁定至表單的 input 中:
const loginData = reactive({
  account: '',
  password: ''
})
<template>
  <form>
    <input
      v-model="loginData.account"
      name="account"
      type="text"
      ...
   />
   <input
      v-model="loginData.password"
      name="password"
      type="text"
      ...
   />
  </form>
</template>
建立登入函式,將帳號及密碼字串夾帶發送至登入 API
const handleLogin = async () => {
  const { data } = await useFetch('/api/login', {
    method: 'POST',
    body: {
      account: loginData.account,
      password: loginData.password
    }
  })
  if (data.value) {
    navigateTo('/')
  }
}
添加 handleLogin 至登入表單內 submit 事件,並添加 .prevent 修飾符。
<template>
  <form @submit.prevent="handleLogin">
    ...
  </form>
</template>
最後,串接完成的 login.vue 檔案,看起來會像這樣:
  <div class="flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
    <div class="w-full max-w-md">
      <div class="flex flex-col items-center">
        <NuxtLink to="/">
          <Icon name="logos:nuxt-icon" size="80" />
        </NuxtLink>
        <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-700">登入帳號</h2>
      </div>
      <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
        <div class="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
          <form class="space-y-6" @submit.prevent="handleLogin">
            <div>
              <label for="account" class="block text-sm font-medium text-gray-700">帳號</label>
              <div class="mt-1">
                <input
                  id="account"
                  v-model="loginData.account"
                  name="account"
                  type="text"
                  autocomplete="account"
                  required
                  class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
                />
              </div>
            </div>
            <div>
              <label for="password" class="block text-sm font-medium text-gray-700">密碼</label>
              <div class="mt-1">
                <input
                  id="password"
                  v-model="loginData.password"
                  name="password"
                  type="password"
                  autocomplete="current-password"
                  required
                  class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
                />
              </div>
            </div>
            <div>
              <button
                type="submit"
                class="flex w-full justify-center rounded-md border border-transparent bg-emerald-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
              >
                登入
              </button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
definePageMeta({
  layout: false
})
const loginData = reactive({
  account: '',
  password: ''
})
const handleLogin = async () => {
  const { data } = await useFetch('/api/login', {
    method: 'POST',
    body: {
      account: loginData.account,
      password: loginData.password
    }
  })
  if (data.value) {
    navigateTo('/')
  }
}
</script>
在 server/api 目錄下建立一個檔案 ./server/api/whoami.get.js:
export default defineEventHandler((event) => {
  setCookie(event, 'access_token', null)
  return 'ok'
})
調整 ./components/LayoutHeader.vue 檔案,呼叫使用 whoami API 取得使用者資訊
<script setup>
const { data: userInfo } = await useFetch('/api/whoami')
</script>
調整 ./components/LayoutHeader.vue 檔案,建立使用者頭像選單,並呈現 userInfo 的資訊
<div class="group relative">
  <label for="avatar" class="cursor-pointer">
    <img
      class="inline-block h-10 w-10 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur hover:bg-opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
      :src="userInfo.avatar"
      alt="使用者選單"
    />
  </label>
  <div class="absolute right-0 hidden w-60 pt-1 text-gray-700 group-hover:block">
    <div
      class="mt-1 divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
    >
      <div class="flex items-center px-4 py-3">
        <img
          :src="userInfo.avatar"
          class="inline-block h-9 w-9 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur"
        />
        <div class="ml-3.5 flex-grow overflow-hidden">
          <p class="overflow-hidden text-ellipsis font-medium">{{ userInfo.nickname }}</p>
          <p class="overflow-hidden text-ellipsis text-xs text-gray-500">
            {{ userInfo.email }}
          </p>
        </div>
      </div>
      <div class="group/menu-item px-1 py-1">
        <NuxtLink
          class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
        >
          <Icon
            class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
            name="ri:pencil-line"
          />
          撰寫文章
        </NuxtLink>
      </div>
      <div class="group/menu-item px-1 py-1">
        <button
          class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
        >
          <Icon
            class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
            name="ri:logout-box-line"
          />
          登出
        </button>
      </div>
    </div>
  </div>
</div>
在 server/api 目錄下建立一個檔案 ./server/api/session.post.js:
export default defineEventHandler((event) => {
  setCookie(event, 'access_token', null)
  return 'ok'
})
調整 ./components/LayoutHeader.vue 檔案,建立 handleLogout 函式用於註銷 Session
const handleLogout = () => {
  $fetch('/api/session', {
    method: 'DELETE'
  }).then(() => {
    userInfo.value = null
  })
}
添加 handleLogout 函式至使用者選單的登出按鈕 click 事件
<button
  class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
  @click="handleLogout"
>
  <Icon
    class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
    name="ri:logout-box-line"
  />
  登出
</button>
調整 ./components/LayoutHeader.vue 檔案,使用 v-if 指令判斷 userInfo 狀態來顯示使用者選單或登入連結
<template>
  <header class="flex w-full justify-center">
    <nav class="flex w-full max-w-7xl items-center justify-between px-6 py-2">
      <div>
        <a href="/">
          <div class="flex items-center justify-between">
            <div class="mr-3">
              <Icon class="h-12 w-12" name="logos:nuxt-icon" />
            </div>
            <div class="hidden h-6 text-2xl font-semibold text-gray-700 sm:block">Nuxt 3 Blog</div>
          </div>
        </a>
      </div>
      <div v-if="userInfo" class="group relative">
        <label for="avatar" class="cursor-pointer">
          <img
            class="inline-block h-10 w-10 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur hover:bg-opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
            :src="userInfo.avatar"
            alt="使用者選單"
          />
        </label>
        <div class="absolute right-0 hidden w-60 pt-1 text-gray-700 group-hover:block">
          <div
            class="mt-1 divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
          >
            <div class="flex items-center px-4 py-3">
              <img
                :src="userInfo.avatar"
                class="inline-block h-9 w-9 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur"
              />
              <div class="ml-3.5 flex-grow overflow-hidden">
                <p class="overflow-hidden text-ellipsis font-medium">{{ userInfo.nickname }}</p>
                <p class="overflow-hidden text-ellipsis text-xs text-gray-500">
                  {{ userInfo.email }}
                </p>
              </div>
            </div>
            <div class="group/menu-item px-1 py-1">
              <NuxtLink
                class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
              >
                <Icon
                  class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
                  name="ri:pencil-line"
                />
                撰寫文章
              </NuxtLink>
            </div>
            <div class="group/menu-item px-1 py-1">
              <button
                class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
                @click="handleLogout"
              >
                <Icon
                  class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
                  name="ri:logout-box-line"
                />
                登出
              </button>
            </div>
          </div>
        </div>
      </div>
      <NuxtLink
        v-else
        class="px-3 py-2 text-gray-700 transition hover:text-emerald-500"
        to="/login"
      >
        登入
      </NuxtLink>
    </nav>
  </header>
</template>
<script setup>
const { data: userInfo } = await useFetch('/api/whoami')
const handleLogout = () => {
  $fetch('/api/session', {
    method: 'DELETE'
  }).then(() => {
    userInfo.value = null
  })
}
</script>
調整 ./layouts/default.vue 檔案,添加自訂元件 至預設插槽上方
<template>
  <div>
    <LayoutHeader />
    <slot />
  </div>
</template>
感謝大家的閱讀,歡迎大家給予建議與討論,也請各位大大鞭小力一些:)
如果對這個 Nuxt 3 系列感興趣,可以訂閱接收通知,也歡迎分享給喜歡或正在學習 Nuxt 3 的夥伴。
範例程式碼