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.vue是app/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-tsc 加 typeCheck: 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;
}
三個要注意的:
- 沒有
tailwind.config.js。主題設定改用 CSS 的@theme區塊寫,跑npx tailwindcss init -p會直接失敗。 - 不用再維護
content陣列。Tailwind 4 自動掃描,以前那種「漏掉一個資料夾、正式站樣式整組消失」的坑沒有了。 @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
這是最容易吃虧的一個。useAsyncData 與 useFetch 回傳的是 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,爬蟲跟社群分享卡片讀到的就是空的。這種問題不會有錯誤訊息,只能自己記得。
同一份資料在多個元件用時,useAsyncData 的 key 要一致,Nuxt 才會共用同一次請求而不是各打各的。
部署:Nitro preset
後端由 Nitro 負責,換部署目標基本上就是換一行:
export default defineNuxtConfig({
nitro: {
preset: 'cloudflare_module'
}
})
常見的還有 node-server、vercel、netlify、static。
要注意 preset 之間的執行環境差異不會被型別擋下來。 這個站踩過一個:workerd 在請求階段解不了動態 import(),配上 defineAsyncComponent 會安靜地渲染出空白而不是報錯——npm run dev 走 vite-node、預渲染是建置時在 Node 跑的,兩者都碰不到那個限制。請求時才 SSR 的頁面一定要用 nuxt preview 在真正的 runtime 驗過,靜態伺服器看不到 worker 的行為。
對照表
| 項目 | Nuxt 3 | Nuxt 4 |
|---|---|---|
| 建專案 | npx nuxi@latest init | npm create nuxt@latest |
| 前端原始碼位置 | 根目錄 | app/ |
| 型別檢查 | 自己裝 vue-tsc + typeCheck: true | nuxt 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/ 底下。