- Published on
 
Next.js 15 + TinaCMS 可视化编辑完整配置指南
- Authors
 
- Name
 - Tails Azimuth
 
概述
本文档记录了如何在 Next.js 15 (App Router) 项目中完整配置 TinaCMS,实现真正的可视化编辑功能。包括:
- TinaCMS 基础配置
 - Next.js Draft Mode 集成
 - 可视化编辑组件实现
 - 预览模式管理
 
技术栈
- 框架: Next.js 15 (App Router)
 - CMS: TinaCMS 2.7.8
 - 样式: Tailwind CSS v4
 - 语言: TypeScript
 
1. 项目初始化和依赖安装
安装 TinaCMS 相关包
npm install tinacms @tinacms/cli @tinacms/auth
关键依赖说明
tinacms: 核心包,提供useTina、tinaField等 hooks@tinacms/cli: 命令行工具,用于生成类型和构建@tinacms/auth: 认证相关功能
2. TinaCMS 配置
创建 TinaCMS 配置文件
tina/config.ts:
import { defineConfig } from 'tinacms'
const branch =
  process.env.GITHUB_BRANCH || process.env.VERCEL_GIT_COMMIT_REF || process.env.HEAD || 'main'
export default defineConfig({
  branch,
  clientId: process.env.NEXT_PUBLIC_TINA_CLIENT_ID,
  token: process.env.TINA_TOKEN,
  build: {
    outputFolder: 'admin',
    publicFolder: 'public',
  },
  // 配置预览模式
  admin: {
    auth: {
      onLogin: async () => {
        window.location.href = `/api/preview/enter?secret=${process.env.TINA_PREVIEW_SECRET}&slug=/`
      },
      onLogout: async () => {
        window.location.href = '/api/preview/exit'
      },
    },
  },
  media: {
    tina: {
      mediaRoot: '',
      publicFolder: 'public',
    },
  },
  schema: {
    collections: [
      {
        name: 'global',
        label: 'Global',
        path: 'content/global',
        format: 'json',
        ui: {
          global: true,
        },
        fields: [
          {
            type: 'object',
            name: 'header',
            label: '网站头部',
            fields: [
              {
                type: 'string',
                name: 'title',
                label: '网站标题',
                required: true,
              },
              {
                type: 'string',
                name: 'subtitle',
                label: '网站副标题',
                required: true,
              },
              {
                type: 'image',
                name: 'logo',
                label: 'Logo图片',
              },
              {
                type: 'object',
                name: 'contact',
                label: '联系信息',
                fields: [
                  {
                    type: 'string',
                    name: 'hotline',
                    label: '咨询热线',
                    required: true,
                  },
                  {
                    type: 'string',
                    name: 'hotlineLabel',
                    label: '热线标签',
                    required: true,
                  },
                  {
                    type: 'image',
                    name: 'qrcode',
                    label: '二维码图片',
                  },
                ],
              },
            ],
          },
          {
            type: 'object',
            name: 'navigation',
            label: '导航菜单',
            list: true,
            fields: [
              {
                type: 'string',
                name: 'name',
                label: '菜单名称',
                required: true,
              },
              {
                type: 'string',
                name: 'href',
                label: '链接地址',
                required: true,
              },
              {
                type: 'boolean',
                name: 'enabled',
                label: '启用',
                description: '是否显示此菜单项',
              },
            ],
          },
        ],
      },
    ],
  },
})
Package.json 脚本配置
{
  "scripts": {
    "dev": "TINA_PUBLIC_IS_LOCAL=true tinacms dev -c \"next dev --turbopack\"",
    "build": "tinacms build && next build",
    "start": "next start",
    "lint": "next lint"
  }
}
3. 环境变量配置
.env.example
# TinaCMS Configuration
NEXT_PUBLIC_TINA_CLIENT_ID=your_tina_client_id_here
TINA_TOKEN=your_tina_token_here
# Preview Mode Secret
TINA_PREVIEW_SECRET=your_preview_secret_here
# Next.js Configuration
NEXT_PUBLIC_SITE_URL=http://localhost:3000
4. Next.js Draft Mode 配置
预览模式 API 路由
src/app/api/preview/enter/route.ts:
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const secret = searchParams.get('secret')
  const slug = searchParams.get('slug') || '/'
  if (secret !== process.env.TINA_PREVIEW_SECRET) {
    return new Response('Invalid token', { status: 401 })
  }
  const draft = await draftMode()
  draft.enable()
  console.log('🎭 [Preview] 启用预览模式,重定向到:', slug)
  redirect(slug)
}
src/app/api/preview/exit/route.ts:
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET() {
  const draft = await draftMode()
  draft.disable()
  console.log('🚫 [Preview] 退出预览模式')
  redirect('/')
}
5. TinaProvider 组件
src/components/TinaProvider.tsx:
'use client'
import { ReactNode } from 'react'
import { useTina } from 'tinacms/dist/react'
interface TinaProviderProps {
  children: ReactNode
  data: any // TinaCMS数据
}
export default function TinaProvider({ children, data }: TinaProviderProps) {
  // 在预览模式下启用TinaCMS编辑功能
  if (data) {
    const { data: tinaData } = useTina({
      query: data.query || '',
      variables: data.variables || {},
      data: data.data || {},
    })
    return <>{children}</>
  }
  return <>{children}</>
}
6. 可视化编辑组件实现
Header 组件示例
src/components/Header.tsx:
'use client'
import React from 'react'
import Image from 'next/image'
import { useTina, tinaField } from 'tinacms/dist/react'
interface HeaderConfig {
  title: string
  subtitle: string
  logo?: string
  contact: {
    hotline: string
    hotlineLabel: string
    qrcode?: string
  }
}
interface HeaderProps {
  config: HeaderConfig
  tinaData?: any // TinaCMS原始数据
}
const Header: React.FC<HeaderProps> = ({ config, tinaData }) => {
  console.log('🎨 [Header] 组件开始渲染')
  // 在预览模式下使用TinaCMS编辑功能
  const { data: editableData } = useTina({
    query: tinaData?.query || '',
    variables: tinaData?.variables || {},
    data: tinaData?.data || { global: null },
  })
  // 在预览模式下优先使用实时数据,否则使用SSG配置
  let headerConfig = config
  if (editableData?.global) {
    console.log('🎭 [Header] 检测到预览模式,使用实时TinaCMS数据')
    const globalData = editableData.global
    headerConfig = {
      title: globalData.header?.title || config.title,
      subtitle: globalData.header?.subtitle || config.subtitle,
      logo: globalData.header?.logo || config.logo,
      contact: {
        hotline: globalData.header?.contact?.hotline || config.contact.hotline,
        hotlineLabel: globalData.header?.contact?.hotlineLabel || config.contact.hotlineLabel,
        qrcode: globalData.header?.contact?.qrcode || config.contact.qrcode,
      },
    }
  }
  // 获取TinaCMS数据用于编辑标记
  const globalNode = editableData?.global
  return (
    <header className="border-b border-gray-300 bg-white px-4 py-6">
      <div className="container mx-auto flex items-center justify-between">
        {/* Logo */}
        <div
          className="mr-6 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100"
          data-tina-field={globalNode && tinaField(globalNode, 'header.logo')}
        >
          {headerConfig.logo && headerConfig.logo.trim() !== '' ? (
            <Image
              src={headerConfig.logo}
              alt="Logo"
              width={64}
              height={64}
              className="rounded-full"
            />
          ) : (
            <div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-500">
              <span className="text-sm font-bold text-white">医</span>
            </div>
          )}
        </div>
        {/* 标题 */}
        <div className="text-left">
          <h1
            className="mb-2 text-4xl font-bold text-gray-800"
            data-tina-field={globalNode && tinaField(globalNode, 'header.title')}
          >
            {headerConfig.title}
          </h1>
          <p
            className="text-base tracking-wide text-gray-600"
            data-tina-field={globalNode && tinaField(globalNode, 'header.subtitle')}
          >
            {headerConfig.subtitle}
          </p>
        </div>
        {/* 联系信息 */}
        <div className="flex items-center space-x-6">
          {/* 二维码 */}
          <div
            className="flex h-20 w-20 items-center justify-center border-2 border-gray-400 bg-gray-100"
            data-tina-field={globalNode && tinaField(globalNode, 'header.contact.qrcode')}
          >
            {headerConfig.contact.qrcode && headerConfig.contact.qrcode.trim() !== '' ? (
              <Image
                src={headerConfig.contact.qrcode}
                alt="咨询二维码"
                width={76}
                height={76}
                className="object-cover"
              />
            ) : (
              <div className="h-16 w-16 bg-gray-300"></div>
            )}
          </div>
          <div className="text-right">
            <p
              className="mb-1 text-sm text-gray-500"
              data-tina-field={globalNode && tinaField(globalNode, 'header.contact.hotlineLabel')}
            >
              {headerConfig.contact.hotlineLabel}
            </p>
            <p
              className="text-2xl font-bold text-blue-600"
              data-tina-field={globalNode && tinaField(globalNode, 'header.contact.hotline')}
            >
              {headerConfig.contact.hotline}
            </p>
          </div>
        </div>
      </div>
    </header>
  )
}
export default Header
7. Layout 集成
src/app/layout.tsx:
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import Header from '../components/Header'
import Navigation from '../components/Navigation'
import TinaProvider from '../components/TinaProvider'
import client from '../../tina/__generated__/client'
import { draftMode } from 'next/headers'
const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
})
export const metadata: Metadata = {
  title: '广东省肿瘤康复学会',
  description: '广东省肿瘤康复学会官方网站',
}
async function getGlobalData() {
  try {
    const result = await client.queries.global({ relativePath: 'index.json' })
    const global = result.data.global
    // 转换数据格式
    const headerConfig = {
      title: global.header?.title || '广东省肿瘤康复学会',
      subtitle: global.header?.subtitle || 'GUANGDONG CANCER REHABILITATION SOCIETY',
      logo: global.header?.logo || undefined,
      contact: {
        hotline: global.header?.contact?.hotline || '020-84829789',
        hotlineLabel: global.header?.contact?.hotlineLabel || '7*24小时咨询热线',
        qrcode: global.header?.contact?.qrcode || undefined,
      },
    }
    const navigationConfig =
      global.navigation
        ?.filter((item) => item !== null)
        .map((item) => ({
          name: item!.name,
          href: item!.href,
          enabled: item!.enabled ?? true,
        })) || []
    return {
      data: result.data,
      query: result.query,
      variables: result.variables,
      header: headerConfig,
      navigation: navigationConfig,
      tinaData: result, // 保存完整的TinaCMS数据
    }
  } catch (error) {
    console.error('Error fetching global data:', error)
    // 返回默认数据...
  }
}
export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  const globalData = await getGlobalData()
  const { isEnabled: isPreview } = await draftMode()
  console.log('🎭 [RootLayout] 预览模式:', isPreview)
  const content = (
    <>
      <Header config={globalData.header} tinaData={globalData.tinaData} />
      <Navigation items={globalData.navigation} tinaData={globalData.tinaData} />
      <main>{children}</main>
    </>
  )
  return (
    <html lang="zh-CN">
      <body className={`${inter.variable} font-sans antialiased`}>
        {isPreview ? <TinaProvider data={globalData.tinaData}>{content}</TinaProvider> : content}
      </body>
    </html>
  )
}
8. 内容数据文件
content/global/index.json:
{
  "header": {
    "title": "广东省肿瘤康复学会",
    "subtitle": "GUANGDONG CANCER REHABILITATION SOCIETY",
    "logo": "",
    "contact": {
      "hotline": "020-84829789",
      "hotlineLabel": "7*24小时咨询热线",
      "qrcode": ""
    }
  },
  "navigation": [
    {
      "name": "首页",
      "href": "/",
      "enabled": true
    },
    {
      "name": "关于学会",
      "href": "/about",
      "enabled": true
    }
  ]
}
9. 使用流程
开发环境设置
- 复制 
.env.example到.env.local - 填入 TinaCMS 凭据和预览密钥
 - 运行 
npm run dev 
编辑流程
- 访问 
/admin进入 TinaCMS 管理界面 - 编辑内容后,TinaCMS 自动启用预览模式
 - 在预览模式下,页面元素显示编辑标记
 - 点击编辑标记可直接在页面上编辑
 - 编辑完成后内容实时同步
 
可编辑元素
- 文本内容: 使用 
data-tina-field={tinaField(node, 'fieldPath')} - 图片内容: 使用 
data-tina-field={tinaField(node, 'imagePath')} - 列表内容: 使用 
data-tina-field={tinaField(node, 'listPath')} 
10. 关键技术要点
1. 模式区分
- 生产模式: 使用静态数据,性能最佳
 - 预览模式: 使用 TinaCMS 实时数据,支持可视化编辑
 
2. 数据流
TinaCMS Admin → Draft Mode → useTina Hook → tinaField → 可视化编辑
3. 组件设计原则
- 支持 SSG 静态数据和 TinaCMS 实时数据
 - 只在预览模式下添加编辑标记
 - 保持原有交互功能(如导航点击)
 - 提供合理的默认值和占位符
 
4. 性能优化
- TinaProvider 只在预览模式下包装组件
 - 使用条件渲染避免不必要的 TinaCMS 代码加载
 - 静态数据优先,实时数据作为增强
 
11. 常见问题和解决方案
图片路径问题
如果图片路径被 TinaCMS 编码导致错误,可以:
- 使用 
skipPaths配置跳过特定字段 - 在组件中对图片路径进行额外处理
 - 使用原始数据作为 fallback
 
类型安全
- 使用 TinaCMS 生成的类型文件
 - 为组件 props 定义明确的接口
 - 使用可选链操作符处理可能为空的数据
 
调试技巧
- 在组件中添加 console.log 查看数据流
 - 使用浏览器开发工具检查 
data-tina-field属性 - 通过预览模式 API 路由测试状态切换
 
总结
通过以上配置,可以实现:
- 真正的可视化编辑: 点击页面元素直接编辑
 - 性能优化: 预览模式和生产模式分离
 - 用户体验: 保持原有交互功能
 - 开发体验: 类型安全和调试友好
 
这套方案适用于任何需要内容管理功能的 Next.js 项目,可以根据具体需求调整 schema 配置和组件实现。