Nuxt 与 Strapi CMS 深度集成指南
为什么选择 Nuxt + Strapi 组合
Headless CMS 的价值
在传统的 CMS 架构中(如 WordPress),前端展示和后端内容管理紧密耦合。这种架构在内容分发渠道单一的时代运作良好,但在今天多渠道、多终端的环境下显得力不从心。
Headless CMS 解决的问题:
- 多渠道内容分发:同一套内容可以同时服务于网站、APP、小程序、电子屏幕等不同终端
- 技术栈自由:前端可以使用任何技术,不受 CMS 限制
- 更好的性能:API 驱动的架构天然适合缓存和 CDN 加速
- 开发体验:前后端可以独立开发、独立部署
- 安全性:内容管理后台与公开网站分离,减少攻击面
为什么是 Strapi
在众多 Headless CMS 中,Strapi 具有独特优势:
| 特性 | Strapi | 其他方案 |
|---|---|---|
| 开源 & 免费 | ✅ 完全开源 | 部分开源或付费 |
| 自托管 | ✅ 数据掌控在自己手中 | 多数是 SaaS |
| 定制性 | ✅ 可深度定制 | 通常受限 |
| 技术栈 | Node.js,前端开发者友好 | 各异 |
| 社区 | 活跃的开源社区 | 取决于具体产品 |
| 学习曲线 | 友好,1-2 天可上手 | 各异 |
为什么是 Nuxt
Nuxt 作为 Vue.js 的全栈框架,与 Strapi 配合有天然优势:
- 服务端渲染:对 SEO 至关重要
- 自动路由:基于文件系统,减少配置
- 数据获取:内置 useFetch/useAsyncData,完美配合 API
- 静态生成:可以预渲染页面,极致性能
- Vue 生态:丰富的组件库和工具
架构设计
整体架构图
┌─────────────────────────────────────────────────────────────┐
│ 用户 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CDN / Edge Network │
│ (Cloudflare / Vercel) │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Nuxt 3 Frontend │ │ Strapi Backend │
│ ───────────────────── │ │ ───────────────────── │
│ • SSR / SSG │────▶│ • Content API │
│ • SEO 优化 │ │ • Media Library │
│ • 用户认证 │ │ • 用户管理 │
│ • 动态路由 │ │ • 权限控制 │
└─────────────────────────┘ └─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Database │
│ (PostgreSQL/MySQL) │
└─────────────────────────┘
数据流设计
在 Nuxt + Strapi 架构中,数据流如下:
内容发布流程:
编辑在 Strapi 创建/更新内容
│
▼
保存到数据库
│
▼
触发 Webhook(可选)
│
▼
Nuxt 重新构建/清除缓存
│
▼
用户访问看到新内容
用户请求流程:
用户访问页面
│
▼
Nuxt 服务端收到请求
│
▼
调用 Strapi API 获取数据
│
▼
渲染 HTML 返回给用户
│
▼
客户端水合,页面可交互
环境搭建
步骤一:创建 Strapi 项目
# 创建 Strapi 项目
npx create-strapi-app@latest my-cms --quickstart
# 或者指定数据库
npx create-strapi-app@latest my-cms --dbclient=postgres
安装完成后,访问 http://localhost:1337/admin 创建管理员账号。
步骤二:创建 Nuxt 项目
# 创建 Nuxt 3 项目
npx nuxi@latest init my-frontend
cd my-frontend
npm install
步骤三:配置环境变量
在两个项目中分别配置环境变量:
Strapi (.env):
# 服务器配置
HOST=0.0.0.0
PORT=1337
# 数据库配置
DATABASE_CLIENT=postgres
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=your_password
# JWT 密钥
JWT_SECRET=your-jwt-secret
ADMIN_JWT_SECRET=your-admin-jwt-secret
APP_KEYS=key1,key2,key3,key4
# 上传配置
UPLOAD_PROVIDER=local
Nuxt (.env):
# Strapi API 地址
NUXT_PUBLIC_STRAPI_URL=http://localhost:1337
NUXT_STRAPI_TOKEN=your-api-token
内容建模
在 Strapi 中创建内容类型
假设我们要建立一个博客系统,需要以下内容类型:
Article(文章):
字段名 类型 说明
─────────────────────────────────────
title 短文本 文章标题
slug UID URL 友好标识
content 富文本 文章内容
excerpt 长文本 摘要
cover 媒体(单个) 封面图片
author 关系(多对一) 作者
category 关系(多对一) 分类
tags 关系(多对多) 标签
publishedAt 日期时间 发布时间
seo 组件 SEO 信息
Category(分类):
字段名 类型 说明
─────────────────────────────────────
name 短文本 分类名称
slug UID URL 友好标识
description 长文本 分类描述
articles 关系(一对多) 该分类下的文章
Author(作者):
字段名 类型 说明
─────────────────────────────────────
name 短文本 作者名称
bio 长文本 个人简介
avatar 媒体(单个) 头像
email 邮箱 联系邮箱
articles 关系(一对多) 该作者的文章
SEO 组件
创建可复用的 SEO 组件:
组件名: shared.seo
字段名 类型 说明
─────────────────────────────────────
metaTitle 短文本 页面标题
metaDescription 长文本 页面描述
metaImage 媒体(单个) 分享图片
keywords 短文本 关键词
canonicalURL 短文本 规范 URL
noIndex 布尔 是否禁止索引
配置 API 权限
在 Strapi 管理后台配置公开 API 权限:
- 进入 Settings → Roles → Public
- 为 Article、Category、Author 开启
find和findOne权限 - 保存设置
Nuxt 端集成
安装 Strapi 模块
npm install @nuxtjs/strapi
配置 Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/strapi'],
strapi: {
url: process.env.NUXT_PUBLIC_STRAPI_URL || 'http://localhost:1337',
prefix: '/api',
version: 'v4',
cookie: {},
cookieName: 'strapi_jwt'
},
runtimeConfig: {
strapi: {
token: process.env.NUXT_STRAPI_TOKEN
},
public: {
strapiUrl: process.env.NUXT_PUBLIC_STRAPI_URL || 'http://localhost:1337'
}
}
})
创建类型定义
为了获得良好的 TypeScript 支持,定义 Strapi 返回的数据类型:
// types/strapi.ts
// Strapi 响应的通用结构
interface StrapiResponse<T> {
data: T
meta: {
pagination?: {
page: number
pageSize: number
pageCount: number
total: number
}
}
}
// 单个实体的结构
interface StrapiEntity<T> {
id: number
attributes: T & {
createdAt: string
updatedAt: string
publishedAt: string
}
}
// 文章类型
interface ArticleAttributes {
title: string
slug: string
content: string
excerpt: string
cover: {
data: StrapiEntity<{
url: string
width: number
height: number
formats: Record<string, { url: string; width: number; height: number }>
}>
}
category: {
data: StrapiEntity<CategoryAttributes>
}
author: {
data: StrapiEntity<AuthorAttributes>
}
tags: {
data: StrapiEntity<TagAttributes>[]
}
seo: SEOComponent
}
// 分类类型
interface CategoryAttributes {
name: string
slug: string
description: string
}
// 作者类型
interface AuthorAttributes {
name: string
bio: string
avatar: {
data: StrapiEntity<{ url: string }>
}
}
// SEO 组件类型
interface SEOComponent {
metaTitle: string
metaDescription: string
metaImage: {
data: StrapiEntity<{ url: string }>
} | null
keywords: string
}
export type Article = StrapiEntity<ArticleAttributes>
export type Category = StrapiEntity<CategoryAttributes>
export type Author = StrapiEntity<AuthorAttributes>
创建 Composables
封装数据获取逻辑,使其可复用:
// composables/useArticles.ts
export const useArticles = () => {
const config = useRuntimeConfig()
const strapiUrl = config.public.strapiUrl
// 获取文章列表
const getArticles = async (options: {
page?: number
pageSize?: number
category?: string
sort?: string
} = {}) => {
const { page = 1, pageSize = 10, category, sort = 'publishedAt:desc' } = options
let filters = ''
if (category) {
filters = `&filters[category][slug][$eq]=${category}`
}
const { data, error } = await useFetch<StrapiResponse<Article[]>>(
`${strapiUrl}/api/articles?populate=*&pagination[page]=${page}&pagination[pageSize]=${pageSize}&sort=${sort}${filters}`
)
return { data, error }
}
// 获取单篇文章
const getArticle = async (slug: string) => {
const { data, error } = await useFetch<StrapiResponse<Article[]>>(
`${strapiUrl}/api/articles?filters[slug][$eq]=${slug}&populate=deep`
)
return {
data: computed(() => data.value?.data?.[0] || null),
error
}
}
// 获取相关文章
const getRelatedArticles = async (articleId: number, categoryId: number, limit: number = 3) => {
const { data, error } = await useFetch<StrapiResponse<Article[]>>(
`${strapiUrl}/api/articles?filters[id][$ne]=${articleId}&filters[category][id][$eq]=${categoryId}&pagination[pageSize]=${limit}&populate=cover,category`
)
return { data, error }
}
return {
getArticles,
getArticle,
getRelatedArticles
}
}
// composables/useCategories.ts
export const useCategories = () => {
const config = useRuntimeConfig()
const strapiUrl = config.public.strapiUrl
const getCategories = async () => {
const { data, error } = await useFetch<StrapiResponse<Category[]>>(
`${strapiUrl}/api/categories?populate=*`
)
return { data, error }
}
const getCategory = async (slug: string) => {
const { data, error } = await useFetch<StrapiResponse<Category[]>>(
`${strapiUrl}/api/categories?filters[slug][$eq]=${slug}&populate=articles.cover`
)
return {
data: computed(() => data.value?.data?.[0] || null),
error
}
}
return {
getCategories,
getCategory
}
}
页面开发
文章列表页
<!-- pages/blog/index.vue -->
<script setup lang="ts">
const route = useRoute()
const { getArticles } = useArticles()
const { getCategories } = useCategories()
// 分页参数
const page = computed(() => Number(route.query.page) || 1)
const category = computed(() => route.query.category as string || '')
// 获取数据
const { data: articlesData, error: articlesError } = await getArticles({
page: page.value,
pageSize: 12,
category: category.value
})
const { data: categoriesData } = await getCategories()
// 解析数据
const articles = computed(() => articlesData.value?.data || [])
const pagination = computed(() => articlesData.value?.meta.pagination)
const categories = computed(() => categoriesData.value?.data || [])
// SEO
useSeoMeta({
title: '博客文章 | 我的网站',
description: '探索我们的博客,获取最新的技术文章、教程和行业见解。',
ogTitle: '博客文章 | 我的网站',
ogDescription: '探索我们的博客,获取最新的技术文章、教程和行业见解。'
})
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-4xl font-bold mb-8">博客文章</h1>
<!-- 分类筛选 -->
<div class="flex gap-4 mb-8 overflow-x-auto pb-2">
<NuxtLink
to="/blog"
:class="['px-4 py-2 rounded-full', !category ? 'bg-blue-600 text-white' : 'bg-gray-100']"
>
全部
</NuxtLink>
<NuxtLink
v-for="cat in categories"
:key="cat.id"
:to="`/blog?category=${cat.attributes.slug}`"
:class="['px-4 py-2 rounded-full whitespace-nowrap',
category === cat.attributes.slug ? 'bg-blue-600 text-white' : 'bg-gray-100']"
>
{{ cat.attributes.name }}
</NuxtLink>
</div>
<!-- 文章列表 -->
<div v-if="articlesError" class="text-red-500">
加载失败,请稍后重试
</div>
<div v-else-if="articles.length === 0" class="text-center py-12">
暂无文章
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<ArticleCard
v-for="article in articles"
:key="article.id"
:article="article"
/>
</div>
<!-- 分页 -->
<Pagination
v-if="pagination && pagination.pageCount > 1"
:current="pagination.page"
:total="pagination.pageCount"
class="mt-12"
/>
</div>
</template>
文章详情页
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const config = useRuntimeConfig()
const { getArticle, getRelatedArticles } = useArticles()
const slug = route.params.slug as string
// 获取文章
const { data: article, error } = await getArticle(slug)
// 404 处理
if (!article.value) {
throw createError({
statusCode: 404,
message: '文章不存在'
})
}
// 获取相关文章
const categoryId = article.value.attributes.category.data?.id
const { data: relatedArticles } = categoryId
? await getRelatedArticles(article.value.id, categoryId, 3)
: { data: ref({ data: [] }) }
// 解析数据
const attrs = computed(() => article.value?.attributes)
const cover = computed(() => {
const coverData = attrs.value?.cover?.data?.attributes
if (!coverData) return null
return {
url: coverData.url.startsWith('/')
? `${config.public.strapiUrl}${coverData.url}`
: coverData.url,
width: coverData.width,
height: coverData.height
}
})
// SEO
useSeoMeta({
title: attrs.value?.seo?.metaTitle || attrs.value?.title,
description: attrs.value?.seo?.metaDescription || attrs.value?.excerpt,
ogTitle: attrs.value?.seo?.metaTitle || attrs.value?.title,
ogDescription: attrs.value?.seo?.metaDescription || attrs.value?.excerpt,
ogImage: cover.value?.url,
ogType: 'article',
articlePublishedTime: attrs.value?.publishedAt,
articleAuthor: attrs.value?.author?.data?.attributes?.name
})
// 结构化数据
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: attrs.value?.title,
description: attrs.value?.excerpt,
image: cover.value?.url,
datePublished: attrs.value?.publishedAt,
author: {
'@type': 'Person',
name: attrs.value?.author?.data?.attributes?.name
}
})
}
]
})
</script>
<template>
<article class="container mx-auto px-4 py-8 max-w-4xl">
<!-- 文章头部 -->
<header class="mb-8">
<div class="flex items-center gap-4 text-sm text-gray-500 mb-4">
<NuxtLink
v-if="attrs?.category?.data"
:to="`/blog?category=${attrs.category.data.attributes.slug}`"
class="text-blue-600 hover:underline"
>
{{ attrs.category.data.attributes.name }}
</NuxtLink>
<span>•</span>
<time :datetime="attrs?.publishedAt">
{{ new Date(attrs?.publishedAt).toLocaleDateString('zh-CN') }}
</time>
</div>
<h1 class="text-4xl md:text-5xl font-bold mb-6">
{{ attrs?.title }}
</h1>
<p v-if="attrs?.excerpt" class="text-xl text-gray-600 mb-6">
{{ attrs.excerpt }}
</p>
<!-- 作者信息 -->
<div v-if="attrs?.author?.data" class="flex items-center gap-4">
<img
v-if="attrs.author.data.attributes.avatar?.data"
:src="`${config.public.strapiUrl}${attrs.author.data.attributes.avatar.data.attributes.url}`"
:alt="attrs.author.data.attributes.name"
class="w-12 h-12 rounded-full"
>
<div>
<p class="font-medium">{{ attrs.author.data.attributes.name }}</p>
<p class="text-sm text-gray-500">{{ attrs.author.data.attributes.bio }}</p>
</div>
</div>
</header>
<!-- 封面图 -->
<figure v-if="cover" class="mb-12">
<img
:src="cover.url"
:alt="attrs?.title"
:width="cover.width"
:height="cover.height"
class="w-full rounded-lg"
>
</figure>
<!-- 文章内容 -->
<div
class="prose prose-lg max-w-none"
v-html="attrs?.content"
/>
<!-- 标签 -->
<div v-if="attrs?.tags?.data?.length" class="mt-12 pt-8 border-t">
<div class="flex flex-wrap gap-2">
<span
v-for="tag in attrs.tags.data"
:key="tag.id"
class="px-3 py-1 bg-gray-100 rounded-full text-sm"
>
{{ tag.attributes.name }}
</span>
</div>
</div>
<!-- 相关文章 -->
<section v-if="relatedArticles?.data?.length" class="mt-16">
<h2 class="text-2xl font-bold mb-8">相关文章</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<ArticleCard
v-for="related in relatedArticles.data"
:key="related.id"
:article="related"
compact
/>
</div>
</section>
</article>
</template>
文章卡片组件
<!-- components/ArticleCard.vue -->
<script setup lang="ts">
interface Props {
article: Article
compact?: boolean
}
const props = withDefaults(defineProps<Props>(), {
compact: false
})
const config = useRuntimeConfig()
const attrs = computed(() => props.article.attributes)
const coverUrl = computed(() => {
const url = attrs.value.cover?.data?.attributes?.url
if (!url) return '/images/placeholder.jpg'
return url.startsWith('/') ? `${config.public.strapiUrl}${url}` : url
})
const formattedDate = computed(() => {
return new Date(attrs.value.publishedAt).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
})
</script>
<template>
<NuxtLink
:to="`/blog/${attrs.slug}`"
class="group block bg-white rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
<div class="aspect-video overflow-hidden">
<img
:src="coverUrl"
:alt="attrs.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
>
</div>
<div :class="['p-4', compact ? 'space-y-2' : 'p-6 space-y-4']">
<div class="flex items-center gap-2 text-sm text-gray-500">
<span v-if="attrs.category?.data" class="text-blue-600">
{{ attrs.category.data.attributes.name }}
</span>
<span v-if="attrs.category?.data">•</span>
<time :datetime="attrs.publishedAt">{{ formattedDate }}</time>
</div>
<h3 :class="['font-bold group-hover:text-blue-600 transition-colors',
compact ? 'text-lg line-clamp-2' : 'text-xl']">
{{ attrs.title }}
</h3>
<p v-if="!compact && attrs.excerpt" class="text-gray-600 line-clamp-3">
{{ attrs.excerpt }}
</p>
</div>
</NuxtLink>
</template>
图片处理
Strapi 的图片需要特殊处理,包括响应式图片和图片优化。
创建图片处理 Composable
// composables/useStrapiMedia.ts
export const useStrapiMedia = () => {
const config = useRuntimeConfig()
// 获取完整图片 URL
const getMediaUrl = (url: string) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${config.public.strapiUrl}${url}`
}
// 获取最佳格式的图片
const getOptimizedImage = (media: any, size: 'thumbnail' | 'small' | 'medium' | 'large' = 'medium') => {
if (!media?.data?.attributes) return null
const attrs = media.data.attributes
const formats = attrs.formats
// 优先使用指定尺寸的格式化版本
if (formats?.[size]) {
return {
url: getMediaUrl(formats[size].url),
width: formats[size].width,
height: formats[size].height
}
}
// 降级到原图
return {
url: getMediaUrl(attrs.url),
width: attrs.width,
height: attrs.height
}
}
// 生成 srcset
const generateSrcset = (media: any) => {
if (!media?.data?.attributes?.formats) return ''
const formats = media.data.attributes.formats
const sizes = ['thumbnail', 'small', 'medium', 'large']
return sizes
.filter(size => formats[size])
.map(size => `${getMediaUrl(formats[size].url)} ${formats[size].width}w`)
.join(', ')
}
return {
getMediaUrl,
getOptimizedImage,
generateSrcset
}
}
响应式图片组件
<!-- components/StrapiImage.vue -->
<script setup lang="ts">
interface Props {
media: any
alt: string
sizes?: string
class?: string
loading?: 'lazy' | 'eager'
}
const props = withDefaults(defineProps<Props>(), {
sizes: '100vw',
loading: 'lazy'
})
const { getMediaUrl, generateSrcset, getOptimizedImage } = useStrapiMedia()
const defaultImage = getOptimizedImage(props.media, 'medium')
const srcset = generateSrcset(props.media)
</script>
<template>
<img
v-if="defaultImage"
:src="defaultImage.url"
:srcset="srcset"
:sizes="sizes"
:alt="alt"
:width="defaultImage.width"
:height="defaultImage.height"
:loading="loading"
:class="class"
>
</template>
用户认证
如果需要实现用户登录、评论等功能,需要集成 Strapi 的认证系统。
认证 Composable
// composables/useAuth.ts
export const useAuth = () => {
const { login: strapiLogin, logout: strapiLogout, user, fetchUser } = useStrapiAuth()
const token = useStrapiToken()
const isAuthenticated = computed(() => !!token.value)
// 登录
const login = async (identifier: string, password: string) => {
try {
await strapiLogin({ identifier, password })
return { success: true }
} catch (error: any) {
return {
success: false,
error: error.data?.error?.message || '登录失败'
}
}
}
// 注册
const register = async (username: string, email: string, password: string) => {
const config = useRuntimeConfig()
try {
const response = await $fetch(`${config.public.strapiUrl}/api/auth/local/register`, {
method: 'POST',
body: { username, email, password }
})
// 自动登录
if (response.jwt) {
token.value = response.jwt
await fetchUser()
}
return { success: true }
} catch (error: any) {
return {
success: false,
error: error.data?.error?.message || '注册失败'
}
}
}
// 登出
const logout = () => {
strapiLogout()
navigateTo('/')
}
return {
user,
isAuthenticated,
login,
register,
logout
}
}
登录页面
<!-- pages/login.vue -->
<script setup lang="ts">
definePageMeta({
middleware: 'guest' // 已登录用户跳转
})
const { login } = useAuth()
const form = reactive({
identifier: '',
password: ''
})
const loading = ref(false)
const error = ref('')
const handleSubmit = async () => {
loading.value = true
error.value = ''
const result = await login(form.identifier, form.password)
if (result.success) {
navigateTo('/dashboard')
} else {
error.value = result.error
}
loading.value = false
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-lg shadow-lg w-full max-w-md">
<h1 class="text-2xl font-bold mb-6">登录</h1>
<div v-if="error" class="bg-red-50 text-red-600 p-4 rounded mb-6">
{{ error }}
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">邮箱或用户名</label>
<input
v-model="form.identifier"
type="text"
required
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
>
</div>
<div>
<label class="block text-sm font-medium mb-1">密码</label>
<input
v-model="form.password"
type="password"
required
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<p class="mt-6 text-center text-gray-600">
还没有账号?
<NuxtLink to="/register" class="text-blue-600 hover:underline">注册</NuxtLink>
</p>
</div>
</div>
</template>
生产部署
Strapi 部署
使用 Docker 部署:
# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 1337
CMD ["npm", "start"]
# docker-compose.yml
version: '3'
services:
strapi:
build: .
ports:
- '1337:1337'
environment:
DATABASE_CLIENT: postgres
DATABASE_HOST: db
DATABASE_PORT: 5432
DATABASE_NAME: strapi
DATABASE_USERNAME: strapi
DATABASE_PASSWORD: ${DB_PASSWORD}
depends_on:
- db
volumes:
- ./public/uploads:/app/public/uploads
db:
image: postgres:14
environment:
POSTGRES_DB: strapi
POSTGRES_USER: strapi
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Nuxt 部署
部署到 Vercel:
# 安装 Vercel CLI
npm i -g vercel
# 部署
vercel
nuxt.config.ts 生产配置:
export default defineNuxtConfig({
nitro: {
preset: 'vercel' // 或 'node-server' 用于自托管
},
// 生产环境 Strapi URL
runtimeConfig: {
public: {
strapiUrl: process.env.NUXT_PUBLIC_STRAPI_URL
}
}
})
常见问题解答
Q: Strapi 图片在 Nuxt 中无法显示?
A: 检查 URL 是否完整。Strapi 返回的图片 URL 可能是相对路径,需要拼接 Strapi 服务器地址。使用 useStrapiMedia composable 统一处理。
Q: 如何处理 Strapi 的分页?
A: Strapi v4 的分页参数格式为 pagination[page]=1&pagination[pageSize]=10,响应的 meta 中包含分页信息。
Q: 如何实现增量静态生成? A: 可以使用 Webhook 配合 Nuxt 的按需重新生成功能。Strapi 内容更新时触发 Webhook,Nuxt 收到后重新生成相关页面。
Q: 开发环境和生产环境如何切换? A: 使用环境变量管理不同环境的 Strapi 地址。本地开发使用 localhost,生产使用真实域名。
总结
Nuxt + Strapi 是构建现代化内容驱动网站的优秀组合。通过本指南,你应该能够:
- 理解架构:掌握 Headless CMS 的工作原理和优势
- 完成集成:从零搭建 Nuxt + Strapi 项目
- 开发页面:创建列表页、详情页等核心功能
- 处理图片:实现响应式图片和优化
- 用户认证:集成 Strapi 的认证系统
- 生产部署:将应用部署到生产环境
这套技术栈特别适合内容密集型网站,如博客、企业官网、电商平台等场景。


