跳到主要內容
前端開發

Nuxt 4 現在怎麼開專案:app/ 目錄、@nuxt/eslint 與 Tailwind 4

對照 Nuxt 3 的建立流程,整理 Nuxt 4 實際變了什麼——srcDir 搬進 app/、nuxi 併入 nuxt CLI、ESLint 改用 @nuxt/eslint 的 flat config、Tailwind 4 改掛 Vite plugin 不再需要 tailwind.config.js,以及預渲染時 useAsyncData 沒 await 會安靜出錯的陷阱。

W

之前寫過 Nuxt 3 專案建立筆記,那篇裡的 npx nuxi@latest init.eslintrc.js@nuxtjs/tailwindcss + tailwind.config.js 現在照著做都會撞牆。

Nuxt 4 的改動不算多,但每一項都在你開專案的第一個小時就會遇到,所以值得單獨記一次。這篇的設定都是這個站自己在跑的。

建專案

nuxi 這個獨立指令已經併回 nuxt 本身,現在的入口是:

npm create nuxt@latest <project-name>

裝完之後,日常指令都掛在 nuxt 底下:

nuxt dev
nuxt build
nuxt preview
nuxt typecheck
nuxt prepare

npx nuxi@latest init 還能動,但它只是轉呼叫上面那個,文件已經不提了。

最大的改動:srcDir 變成 app/

Nuxt 3 的 pages/components/composables/layouts/ 都在根目錄,跟 node_modules/.output/、設定檔混在一起。Nuxt 4 把前端的部分整組搬進 app/

my-app/
├── app/                  ← 前端全部在這裡
│   ├── assets/
│   ├── components/
│   ├── composables/
│   ├── layouts/
│   ├── middleware/
│   ├── pages/
│   ├── plugins/
│   ├── app.vue
│   └── app.config.ts
├── server/               ← 留在根目錄
│   ├── api/
│   ├── routes/
│   └── middleware/
├── content/              ← 留在根目錄
├── public/               ← 留在根目錄
├── nuxt.config.ts
└── package.json

留在根目錄的是 server/content/public/modules/。判準很單純:app/ 底下的東西會被打包進前端 bundle,其他的不會。

實務上的影響:

  • ~/ 別名現在指向 app/,所以 ~/components/Foo.vueapp/components/Foo.vue
  • app/ 底下要引用根目錄的設定檔,得用相對路徑往上跳一層
  • 檔案監看的範圍縮小了,dev server 啟動與 HMR 都比較快

從 Nuxt 3 升上來

不想一次搬完的話,nuxt.config.ts 可以留在舊行為:

export default defineNuxtConfig({
  srcDir: '.',
  dir: {
    app: 'app'
  }
})

但這只是緩衝。新專案直接用 app/,不要為了省一次搬檔案而長期背著一份跟文件不一致的結構。

compatibilityDate

Nuxt 4 的設定檔多了一個一定要填的欄位:

export default defineNuxtConfig({
  compatibilityDate: '2025-07-15'
})

它鎖住 Nitro 與各家部署平台的行為版本。沒填的話升級 Nuxt 時可能會靜靜地換掉某些預設值——填一個日期,之後升版行為就不會自己漂移,等你有空再往前推。

TypeScript

型別檢查不用再自己裝 vue-tsctypeCheck: true 了,直接有指令:

nuxt typecheck

nuxt.config.ts 只留 strict 就好:

export default defineNuxtConfig({
  typescript: {
    strict: true
  }
})

Nuxt 4 會為 app/server/shared/ 各產生一份獨立的 tsconfig,所以在 server/ 底下寫 document.querySelector 會直接被型別擋下來——Nuxt 3 是不會的。

nuxt typecheck 內部會跑 nuxt prepare。如果專案用了 @nuxt/content,它會重建本機的 content 資料庫,dev server 開著的時候跑會把 dev 那邊的文章清空。停掉 dev 再跑。

ESLint:改用 @nuxt/eslint

.eslintrc.js 這種 legacy config 已經不再是 ESLint 的預設,@nuxtjs/eslint-config-typescript 也停在原地了。現在的做法是官方的模組:

npm install -D @nuxt/eslint eslint
export default defineNuxtConfig({
  modules: ['@nuxt/eslint']
})

跑一次 nuxt prepare,它會自動產生一份 eslint.config.mjs

import withNuxt from './.nuxt/eslint.config.mjs'

export default withNuxt({
  rules: {
    'vue/multi-word-component-names': 'off'
  }
})

它比手寫設定好的地方在於:規則是依你實際安裝的模組產生的,Nuxt 自動匯入的那些 API 也已經登記好,不用再像以前那樣為了消掉滿江紅的 no-undef 而整條關掉。

Tailwind 4:不再有 tailwind.config.js

這是變動最大的一塊。@nuxtjs/tailwindcss 那條路已經不需要了,Tailwind 4 自己提供 Vite plugin:

npm install -D tailwindcss @tailwindcss/vite
import tailwindcss from '@tailwindcss/vite'

export default defineNuxtConfig({
  css: ['~/assets/css/main.css'],
  vite: {
    plugins: [tailwindcss()]
  }
})

app/assets/css/main.css

@import "tailwindcss";

@theme {
  --color-brand: #000000;
  --font-sans: "Noto Sans TC", sans-serif;
}

三個要注意的:

  1. 沒有 tailwind.config.js。主題設定改用 CSS 的 @theme 區塊寫,跑 npx tailwindcss init -p 會直接失敗。
  2. 不用再維護 content 陣列。Tailwind 4 自動掃描,以前那種「漏掉一個資料夾、正式站樣式整組消失」的坑沒有了。
  3. @apply 在 scoped style 裡要先 @reference。單檔元件的 <style scoped> 是獨立編譯的,看不到主檔案的 @import

Tailwind 4 本身的其他變更(PostCSS 設定、@theme 的用法)另外寫在 Vite + Vue 3 + Tailwind CSS 4 + shadcn-vue 安裝心得

狀態管理:先問要不要 Pinia

Nuxt 3 那篇裡裝 Pinia 是預設動作。實際上 Nuxt 內建的 useState 已經能處理大部分情況——它是 SSR 友善的共用 ref,伺服器端算出來的值會被序列化帶到瀏覽器:

export const useCounter = () => useState<number>('counter', () => 0)

在任何元件裡呼叫同一個 key 就拿到同一份狀態。什麼時候才真的需要 Pinia:

  • 需要 store 的 actions/getters 組織一大包邏輯
  • 需要 devtools 的時間旅行除錯
  • 已經有一套 Pinia store 要從別的專案搬過來

不然多一層抽象只是多一層要維護的東西。

資料取得:預渲染一定要 await

這是最容易吃虧的一個。useAsyncDatauseFetch 回傳的是 ref,在 setup 階段沒有 await,預渲染就會在資料回來之前先把 HTML 吐出去

<script setup lang="ts">
// ✗ 預渲染出來的 HTML 裡 posts 是空的
const { data: posts } = useAsyncData('posts', () => queryCollection('posts').all())

// ✓
const { data: posts } = await useAsyncData('posts', () => queryCollection('posts').all())
</script>

畫面上看不出差別——瀏覽器端 hydrate 完照樣有資料。壞掉的是原始 HTML,爬蟲跟社群分享卡片讀到的就是空的。這種問題不會有錯誤訊息,只能自己記得。

同一份資料在多個元件用時,useAsyncDatakey 要一致,Nuxt 才會共用同一次請求而不是各打各的。

部署:Nitro preset

後端由 Nitro 負責,換部署目標基本上就是換一行:

export default defineNuxtConfig({
  nitro: {
    preset: 'cloudflare_module'
  }
})

常見的還有 node-serververcelnetlifystatic

要注意 preset 之間的執行環境差異不會被型別擋下來。 這個站踩過一個:workerd 在請求階段解不了動態 import(),配上 defineAsyncComponent 會安靜地渲染出空白而不是報錯——npm run dev 走 vite-node、預渲染是建置時在 Node 跑的,兩者都碰不到那個限制。請求時才 SSR 的頁面一定要用 nuxt preview 在真正的 runtime 驗過,靜態伺服器看不到 worker 的行為。

對照表

項目Nuxt 3Nuxt 4
建專案npx nuxi@latest initnpm create nuxt@latest
前端原始碼位置根目錄app/
型別檢查自己裝 vue-tsc + typeCheck: truenuxt typecheck
ESLint.eslintrc.js + @nuxtjs/eslint-config-typescript@nuxt/eslint + eslint.config.mjs
Tailwind@nuxtjs/tailwindcss + tailwind.config.js@tailwindcss/vite + @theme
相容性鎖定compatibilityDate
全域狀態大多直接上 Pinia先看 useState 夠不夠

沒有變的部分

Nuxt 3 那篇裡有一半以上仍然完全適用,不用重學:

  • 檔案系統路由([id].vue[...slug].vue、巢狀路由)
  • 404 頁面與 setResponseStatus(404)
  • <NuxtPage /><NuxtLink><NuxtLayout>
  • Layout 與具名插槽、definePageMeta
  • 元件自動載入與路徑前綴命名(base/Button.vue<BaseButton />
  • .client / .server 後綴

差別只在檔案現在放 app/ 底下。

參考資料

分享這篇