Nuxt 生态 精选推荐

Nuxt 与 Strapi CMS 深度集成指南 - 构建现代化全栈应用

HTMLPAGE 团队
20 分钟阅读

详细讲解 Nuxt 3 与 Strapi v4 的集成方案,从架构设计到实战部署。包括数据模型、API 对接、用户认证、图片处理、SEO 优化和生产部署的完整流程。

#Nuxt 3 #Strapi #Headless CMS #全栈开发

Nuxt 与 Strapi CMS 深度集成指南

为什么选择 Nuxt + Strapi 组合

Headless CMS 的价值

在传统的 CMS 架构中(如 WordPress),前端展示和后端内容管理紧密耦合。这种架构在内容分发渠道单一的时代运作良好,但在今天多渠道、多终端的环境下显得力不从心。

Headless CMS 解决的问题

  1. 多渠道内容分发:同一套内容可以同时服务于网站、APP、小程序、电子屏幕等不同终端
  2. 技术栈自由:前端可以使用任何技术,不受 CMS 限制
  3. 更好的性能:API 驱动的架构天然适合缓存和 CDN 加速
  4. 开发体验:前后端可以独立开发、独立部署
  5. 安全性:内容管理后台与公开网站分离,减少攻击面

为什么是 Strapi

在众多 Headless CMS 中,Strapi 具有独特优势:

特性Strapi其他方案
开源 & 免费✅ 完全开源部分开源或付费
自托管✅ 数据掌控在自己手中多数是 SaaS
定制性✅ 可深度定制通常受限
技术栈Node.js,前端开发者友好各异
社区活跃的开源社区取决于具体产品
学习曲线友好,1-2 天可上手各异

为什么是 Nuxt

Nuxt 作为 Vue.js 的全栈框架,与 Strapi 配合有天然优势:

  1. 服务端渲染:对 SEO 至关重要
  2. 自动路由:基于文件系统,减少配置
  3. 数据获取:内置 useFetch/useAsyncData,完美配合 API
  4. 静态生成:可以预渲染页面,极致性能
  5. 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 权限:

  1. 进入 Settings → Roles → Public
  2. 为 Article、Category、Author 开启 findfindOne 权限
  3. 保存设置

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 是构建现代化内容驱动网站的优秀组合。通过本指南,你应该能够:

  1. 理解架构:掌握 Headless CMS 的工作原理和优势
  2. 完成集成:从零搭建 Nuxt + Strapi 项目
  3. 开发页面:创建列表页、详情页等核心功能
  4. 处理图片:实现响应式图片和优化
  5. 用户认证:集成 Strapi 的认证系统
  6. 生产部署:将应用部署到生产环境

这套技术栈特别适合内容密集型网站,如博客、企业官网、电商平台等场景。

参考资源