GoWind CMS provides four frontend applications (Vue/React/Taro/Flutter). This tutorial uses React (Next.js) as the main thread and walks you through the complete frontend application development process: from project initialization, API integration, and page development, to internationalization and deployment.
| Version | Framework | Rendering | Target Platform | SEO | Use Case |
|---|
| React | Next.js 15 | SSR / SSG | PC Web | Excellent | Corporate websites, news portals |
| Vue | Nuxt.js 3 | SSR / SSG | PC Web | Excellent | Blogs, content sites |
| Taro | Taro 4 | CSR | Mini programs + H5 | Poor | WeChat mini programs, mobile H5 |
| Flutter | Flutter 3 | Native rendering | Android/iOS/Web | Poor | Cross-platform native apps |
This tutorial uses React (Next.js) as the example, because it is the most common solution for enterprise-grade content sites.
npx create-next-app@latest cms-frontend-react \
--typescript \
--app \
--tailwind \
--eslint
cd cms-frontend-react
pnpm add axios
pnpm add next-intl
pnpm add @radix-ui/react-slot class-variance-authority clsx tailwind-merge lucide-react
pnpm add zustand
pnpm add react-markdown remark-gfm rehype-raw
pnpm add -D @types/node
cms-frontend-react/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── [locale]/ # i18n routes
│ │ │ ├── layout.tsx # Root layout (navbar, footer)
│ │ │ ├── page.tsx # Home page
│ │ │ ├── posts/
│ │ │ │ ├── page.tsx # Post list
│ │ │ │ └── [slug]/
│ │ │ │ └── page.tsx # Post detail
│ │ │ ├── categories/
│ │ │ │ └── [slug]/
│ │ │ │ └── page.tsx # Posts by category
│ │ │ ├── tags/
│ │ │ │ └── [slug]/
│ │ │ │ └── page.tsx # Posts by tag
│ │ │ ├── about/
│ │ │ │ └── page.tsx # About page
│ │ │ ├── login/
│ │ │ │ └── page.tsx # Login page
│ │ │ └── profile/
│ │ │ └── page.tsx # Profile center
│ │ └── api/ # Next.js API routes (optional proxy)
│ ├── components/ # React components
│ │ ├── layout/ # Layout components
│ │ │ ├── Header.tsx
│ │ │ ├── Footer.tsx
│ │ │ └── Sidebar.tsx
│ │ ├── post/ # Post components
│ │ │ ├── PostCard.tsx
│ │ │ ├── PostList.tsx
│ │ │ └── CommentSection.tsx
│ │ └── ui/ # Base UI components
│ ├── lib/ # Utility libraries
│ │ ├── api/ # API clients
│ │ │ ├── client.ts # Axios instance
│ │ │ ├── post.ts # Post API
│ │ │ ├── category.ts # Category API
│ │ │ └── auth.ts # Auth API
│ │ ├── i18n/ # i18n configuration
│ │ └── utils.ts # Common utilities
│ ├── messages/ # i18n messages
│ │ ├── zh-CN.json
│ │ └── en-US.json
│ ├── stores/ # Zustand state management
│ │ ├── auth.ts # Auth state
│ │ └── settings.ts # Site settings
│ └── middleware.ts # Next.js middleware
├── public/ # Static assets
├── .env.local # Environment variables
├── next.config.ts # Next.js configuration
├── tailwind.config.ts # Tailwind configuration
└── package.json
NEXT_PUBLIC_API_BASE_URL=http://localhost:6700/app/v1
NEXT_PUBLIC_DEFAULT_LOCALE=zh-CN
NEXT_PUBLIC_SITE_URL=http://localhost:3000
import axios from 'axios';
const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:6700/app/v1';
export const apiClient = axios.create({
baseURL,
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
apiClient.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
apiClient.interceptors.response.use(
(response) => response.data,
(error) => {
if (typeof window !== 'undefined' && error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
},
);
import { apiClient } from './client';
export interface PostListParams {
locale?: string;
page?: number;
pageSize?: number;
orderBy?: string;
orderDesc?: boolean;
search?: string;
categoryId?: number;
tagId?: number;
}
export async function getPosts(params: PostListParams = {}) {
return apiClient.get('/posts', { params });
}
export async function getPostBySlug(slug: string, locale: string) {
return apiClient.get(`/posts/${slug}`, { params: { locale } });
}
export async function getPostsByCategory(categorySlug: string, locale: string, page = 1) {
return apiClient.get('/posts', {
params: { locale, page, categorySlug },
});
}
export async function getCategories(locale?: string) {
return apiClient.get('/categories', { params: { locale } });
}
export async function getNavigations() {
return apiClient.get('/navigations');
}
export async function login(username: string, password: string) {
return apiClient.post('/login', { username, password });
}
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { getNavigations, getSiteSettings } from '@/lib/api';
import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer';
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const [messages, navigations, siteSettings] = await Promise.all([
getMessages(),
getNavigations(),
getSiteSettings(locale),
]);
return (
<NextIntlClientProvider messages={messages}>
<div className="flex min-h-screen flex-col">
<Header
navigations={navigations.items}
siteName={siteSettings.name}
logo={siteSettings.logo}
/>
<main className="flex-1">{children}</main>
<Footer siteSettings={siteSettings} />
</div>
</NextIntlClientProvider>
);
}
import { getPosts, getCategories } from '@/lib/api';
import PostCard from '@/components/post/PostCard';
import Pagination from '@/components/ui/Pagination';
export default async function HomePage({
params: { locale },
searchParams: { page = '1' },
}: {
params: { locale: string };
searchParams: { page?: string };
}) {
const pageNum = Number(page);
const [postsData, categoriesData] = await Promise.all([
getPosts({ locale, page: pageNum, pageSize: 9 }),
getCategories(locale),
]);
return (
<div className="container mx-auto px-4 py-8">
{}
<section className="mb-12 text-center">
<h1 className="text-4xl font-bold mb-4">最新内容</h1>
<p className="text-gray-600">探索精彩文章与资讯</p>
</section>
{}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{postsData.items.map((post) => (
<PostCard key={post.id} post={post} locale={locale} />
))}
</div>
{}
<Pagination
current={pageNum}
total={postsData.total}
pageSize={9}
/>
</div>
);
}
import Link from 'next/link';
import Image from 'next/image';
import { format } from 'date-fns';
interface PostCardProps {
post: {
id: number;
title: string;
slug: string;
summary: string;
thumbnail?: string;
createdAt: string;
categories?: { name: string; slug: string }[];
};
locale: string;
}
export default function PostCard({ post, locale }: PostCardProps) {
return (
<Link href={`/${locale}/posts/${post.slug}`}>
<article className="rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow">
{post.thumbnail && (
<Image
src={post.thumbnail}
alt={post.title}
width={400}
height={200}
className="w-full h-48 object-cover"
/>
)}
<div className="p-5">
{post.categories && (
<div className="flex gap-2 mb-2">
{post.categories.map((cat) => (
<span key={cat.slug} className="text-xs text-blue-600">
{cat.name}
</span>
))}
</div>
)}
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
{post.title}
</h2>
<p className="text-gray-600 text-sm line-clamp-3">{post.summary}</p>
<time className="text-xs text-gray-400 mt-3 block">
{format(new Date(post.createdAt), 'yyyy-MM-dd')}
</time>
</div>
</article>
</Link>
);
}
import { getPostBySlug } from '@/lib/api';
import CommentSection from '@/components/post/CommentSection';
import { notFound } from 'next/navigation';
export async function generateMetadata({
params: { locale, slug },
}: {
params: { locale: string; slug: string };
}) {
const post = await getPostBySlug(slug, locale);
return {
title: post.title,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
images: post.thumbnail ? [post.thumbnail] : [],
},
};
}
export default async function PostDetailPage({
params: { locale, slug },
}: {
params: { locale: string; slug: string };
}) {
let post;
try {
post = await getPostBySlug(slug, locale);
} catch {
notFound();
}
return (
<article className="container mx-auto px-4 py-8 max-w-3xl">
{}
<header className="mb-8">
<h1 className="text-3xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>作者:{post.author?.name}</span>
<time>{new Date(post.createdAt).toLocaleDateString(locale)}</time>
</div>
</header>
{}
<div
className="prose prose-lg max-w-none mb-12"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
{}
{post.tags && (
<div className="flex gap-2 mb-12">
{post.tags.map((tag) => (
<Link
key={tag.slug}
href={`/${locale}/tags/${tag.slug}`}
className="px-3 py-1 bg-gray-100 rounded-full text-sm hover:bg-gray-200"
>
#{tag.name}
</Link>
))}
</div>
)}
{}
<CommentSection postId={post.id} />
</article>
);
}
import { getCategories, getPosts } from '@/lib/api';
import PostCard from '@/components/post/PostCard';
export default async function CategoryPage({
params: { locale, slug },
searchParams: { page = '1' },
}: {
params: { locale: string; slug: string };
searchParams: { page?: string };
}) {
const categories = await getCategories(locale);
const category = categories.items.find((c) => c.slug === slug);
if (!category) notFound();
const posts = await getPosts({
locale,
categoryId: category.id,
page: Number(page),
pageSize: 9,
});
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">分类:{category.name}</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.items.map((post) => (
<PostCard key={post.id} post={post} locale={locale} />
))}
</div>
</div>
);
}
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { login } from '@/lib/api/auth';
export default function LoginPage({ params: { locale } }: { params: { locale: string } }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const t = useTranslations('auth');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await login(username, password);
localStorage.setItem('token', res.token);
localStorage.setItem('user', JSON.stringify(res.user));
router.push(`/${locale}/profile`);
} catch (err: any) {
setError(err.response?.data?.message || '登录失败');
} finally {
setLoading(false);
}
};
return (
<div className="container mx-auto max-w-md py-20">
<h1 className="text-2xl font-bold mb-6 text-center">{t('login')}</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
placeholder={t('username')}
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2 border rounded"
required
/>
<input
type="password"
placeholder={t('password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border rounded"
required
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t('loggingIn') : t('login')}
</button>
</form>
</div>
);
}
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: number;
username: string;
avatar?: string;
}
interface AuthState {
token: string | null;
user: User | null;
isAuthenticated: boolean;
setAuth: (token: string, user: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
isAuthenticated: false,
setAuth: (token, user) =>
set({ token, user, isAuthenticated: true }),
logout: () =>
set({ token: null, user: null, isAuthenticated: false }),
}),
{ name: 'auth-storage' },
),
);
import { getRequestConfig } from 'next-intl/server';
export const locales = ['zh-CN', 'en-US'] as const;
export type Locale = (typeof locales)[number];
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`./messages/${locale}.json`)).default,
}));
{
"auth": {
"login": "登录",
"logout": "退出",
"username": "用户名",
"password": "密码",
"loggingIn": "登录中..."
},
"post": {
"latest": "最新文章",
"readMore": "阅读更多",
"comments": "评论",
"leaveComment": "发表评论"
},
"common": {
"loading": "加载中...",
"noData": "暂无数据",
"search": "搜索"
}
}
import createMiddleware from 'next-intl/middleware';
import { locales } from './i18n';
export default createMiddleware({
locales,
defaultLocale: 'zh-CN',
localePrefix: 'always',
});
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
export async function generateMetadata({
params: { locale, slug },
}: {
params: { locale: string; slug: string };
}) {
const post = await getPostBySlug(slug, locale);
return {
title: post.title,
description: post.summary,
keywords: post.tags?.map((t) => t.name),
alternates: {
canonical: `/${locale}/posts/${slug}`,
languages: {
'zh-CN': `/zh-CN/posts/${slug}`,
'en-US': `/en-US/posts/${slug}`,
},
},
openGraph: {
title: post.title,
description: post.summary,
type: 'article',
publishedTime: post.createdAt,
images: post.thumbnail ? [{ url: post.thumbnail }] : [],
},
};
}
function JsonLd({ post }: { post: any }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.summary,
image: post.thumbnail,
datePublished: post.createdAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author?.name,
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
import { getPosts } from '@/lib/api';
export default async function sitemap() {
const posts = await getPosts({ pageSize: 1000 });
const locales = ['zh-CN', 'en-US'];
const postUrls = posts.items.flatMap((post) =>
locales.map((locale) => ({
url: `${process.env.NEXT_PUBLIC_SITE_URL}/${locale}/posts/${post.slug}`,
lastModified: new Date(post.updatedAt || post.createdAt),
})),
);
return [
{ url: process.env.NEXT_PUBLIC_SITE_URL!, lastModified: new Date() },
...postUrls,
];
}
export default function robots() {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/profile/'],
},
sitemap: `${process.env.NEXT_PUBLIC_SITE_URL}/sitemap.xml`,
};
}
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
services:
cms-react:
build: .
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_BASE_URL=https://api.cms.gowind.cloud/app/v1
- NEXT_PUBLIC_DEFAULT_LOCALE=zh-CN
restart: always
| Environment | API Address |
|---|
| Development | http://localhost:6700/app/v1 |
| Testing | https://api.test.cms.gowind.cloud/app/v1 |
| Production | https://api.cms.gowind.cloud/app/v1 |
import Image from 'next/image';
<Image
src={post.thumbnail}
alt={post.title}
width={400}
height={200}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
export const revalidate = 60;
export default async function PostDetailPage({ params }) {
}
export async function generateStaticParams() {
const posts = await getPosts({ pageSize: 100 });
return posts.items.map((post) => ({
locale: 'zh-CN',
slug: post.slug,
}));
}
| Check Item | Description |
|---|
| Project initialization | Next.js + TypeScript + Tailwind |
| API layer | Axios client + API function wrappers |
| Page development | Home + post list + detail + categories/tags |
| Authentication | Login page + token management + state management |
| Internationalization | next-intl configuration + message files |
| SEO optimization | Metadata + sitemap + structured data |
| Build and deploy | Docker image + environment variables |
| Performance optimization | Image optimization + ISR + prefetching |