上一期,我们已经在 Strapi 中创建了 8 个内容模型:网站设置、项目案例、服务、团队成员、文章、文章分类、客户评价和联系表单。
后台有了数据,接下来就要让网站真正跑起来。
这一篇使用 Strapi 5 + Next.js App Router + TypeScript + Tailwind CSS,完成三个目标:
-
从 Strapi 获取项目案例,并展示列表和详情。
-
获取网站全局设置,动态显示 Logo 和联系方式。
-
实现联系表单提交,将访客需求保存到 Strapi。
最终效果就是:编辑人员在后台更新内容,前端自动读取;访客在官网提交需求,后台能够收到记录。
一、先检查项目结构和环境变量
假设上一期的 Strapi 已经运行在本地 http://localhost:1337,Next.js 运行在 http://localhost:3000。
前端项目可以按下面的方式组织:
my-design-site/
├── app/
│ ├── api/
│ │ └── contact/
│ │ └── route.ts
│ ├── projects/
│ │ ├── [slug]/
│ │ │ └── page.tsx
│ │ └── page.tsx
│ └── contact/
│ └── page.tsx
├── components/
│ └── ContactForm.tsx
├── lib/
│ └── strapi.ts
└── .env.local
在 .env.local 中配置:
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your_server_side_api_token
这里有个非常重要的细节:STRAPI_API_TOKEN 不能添加 NEXT_PUBLIC_ 前缀,否则可能被暴露给浏览器。
这个 Token 应在 Strapi 后台创建,并根据实际需要限制权限。不要把它写进前端组件、提交到 Git,或直接返回给浏览器。
开发时可以使用本地地址;部署后,需要将 STRAPI_URL 改成服务器可以访问的 Strapi 地址。
二、统一封装 Strapi 请求
不要在每个页面里重复编写请求逻辑。先创建 lib/strapi.ts。
const STRAPI_URL = process.env.STRAPI_URL;
if (!STRAPI_URL) {
throw new Error("Missing STRAPI_URL");
}
type StrapiOptions = {
token?: string;
revalidate?: number;
};
export async function strapiFetch<T>(
path: string,
options: StrapiOptions = {},
): Promise<T> {
const url = new URL(path, STRAPI_URL);
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (options.token) {
headers.Authorization = `Bearer ${options.token}`;
}
const response = await fetch(url, {
headers,
next: {
revalidate: options.revalidate ?? 60,
},
});
if (!response.ok) {
throw new Error(
`Strapi request failed: ${response.status}`,
);
}
return response.json() as Promise<T>;
}
这个函数统一处理 API 地址、Token、缓存和错误状态。
需要注意:默认的 60 秒缓存适用于普通展示内容,不适合所有业务。联系表单提交、用户私密信息等请求不能照搬这一缓存策略。
另外,strapiFetch 默认面向服务端使用。不要在客户端组件中调用它来获取带有服务器 Token 的数据。
三、获取项目列表并展示案例卡片
先在 Strapi 的 Project 模型中创建几条测试数据,例如:
-
品牌视觉升级
-
商业空间设计
-
企业官网改版
确保每条记录都已经发布,并且至少填写标题、Slug、摘要和封面图片。
1. 定义数据类型
创建 lib/projects.ts:
import { strapiFetch } from "./strapi";
type StrapiMedia = {
url: string;
alternativeText?: string | null;
};
export type Project = {
documentId: string;
title: string;
slug: string;
summary?: string | null;
cover?: StrapiMedia | null;
};
type StrapiResponse<T> = {
data: T[];
};
export async function getProjects() {
const result = await strapiFetch<
StrapiResponse<Project>
>(
"/api/projects?populate[cover]=true&sort[0]=sortOrder:asc",
);
return result.data;
}
export function getMediaUrl(
path?: string | null,
) {
if (!path) return "";
return new URL(
path,
process.env.STRAPI_URL,
).toString();
}
这里使用 Strapi 5 的 documentId 作为内容标识。媒体字段的返回结构应以实际接口响应为准;如果你配置了不同的字段名或媒体关系,需要同步调整类型。
2. 创建案例列表页
在 app/projects/page.tsx 中:
import Image from "next/image";
import Link from "next/link";
import {
getProjects,
getMediaUrl,
} from "@/lib/projects";
export default async function ProjectsPage() {
const projects = await getProjects();
return (
<main className="mx-auto max-w-6xl px-6 py-16">
<header className="mb-10">
<p className="text-sm uppercase tracking-widest text-gray-500">
Selected Work
</p>
<h1 className="mt-3 text-4xl font-bold">
项目案例
</h1>
<p className="mt-4 text-gray-600">
用设计解决问题,让品牌被更多人看见。
</p>
</header>
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => {
const imageUrl = getMediaUrl(
project.cover?.url,
);
return (
<Link
key={project.documentId}
href={`/projects/${project.slug}`}
className="group"
>
{imageUrl && (
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-gray-100">
<Image
src={imageUrl}
alt={
project.cover?.alternativeText ??
project.title
}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition duration-300 group-hover:scale-105"
/>
</div>
)}
<h2 className="mt-4 text-xl font-semibold">
{project.title}
</h2>
{project.summary && (
<p className="mt-2 text-sm text-gray-600">
{project.summary}
</p>
)}
</Link>
);
})}
</div>
</main>
);
}
这里使用 Server Component 直接在服务端获取数据,不需要额外编写客户端请求,也避免把服务器 Token 传到浏览器。
如果使用 Next.js 的 Image 组件展示 Strapi 图片,还要在 next.config.ts 中配置允许访问的图片域名。例如:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "1337",
pathname: "/uploads/**",
},
],
},
};
export default nextConfig;
部署时应将配置改为实际的图片域名,并重启 Next.js。
四、实现项目详情页
列表页解决的是"展示哪些项目",详情页解决的是"一个项目的完整内容"。
创建 app/projects/[slug]/page.tsx:
import Image from "next/image";
import { notFound } from "next/navigation";
import {
getProjects,
getMediaUrl,
} from "@/lib/projects";
type Props = {
params: Promise<{ slug: string }>;
};
export default async function ProjectDetailPage({
params,
}: Props) {
const { slug } = await params;
const projects = await getProjects();
const project = projects.find(
(item) => item.slug === slug,
);
if (!project) {
notFound();
}
const imageUrl = getMediaUrl(
project.cover?.url,
);
return (
<main className="mx-auto max-w-4xl px-6 py-16">
<h1 className="text-4xl font-bold">
{project.title}
</h1>
{project.summary && (
<p className="mt-4 text-lg text-gray-600">
{project.summary}
</p>
)}
{imageUrl && (
<div className="relative mt-10 aspect-[16/9] overflow-hidden rounded-2xl">
<Image
src={imageUrl}
alt={
project.cover?.alternativeText ??
project.title
}
fill
sizes="(max-width: 768px) 100vw, 896px"
className="object-cover"
/>
</div>
)}
</main>
);
}
这份代码是一个最小可运行示例:先通过列表数据找到对应的项目,再显示详情。
正式项目不建议一直用这种方式查询所有项目。更好的做法是根据 slug 直接请求 Strapi,例如使用过滤条件 filters[slug][$eq],并通过 populate 获取需要的媒体和关联内容。项目数量增长后,这种方式更高效。
此外,这里只展示了封面和摘要。要显示完整项目正文,还需要在 Strapi 的 Project 模型中配置相应的富文本字段,并根据返回的数据结构编写富文本渲染组件。
五、获取网站全局设置
网站名称、Logo、联系电话等信息不应该散落在多个页面里硬编码。
上一期创建的 Site Setting 是 Single Type,可以通过对应的 API 读取。
// lib/site-settings.ts
import { strapiFetch } from "./strapi";
type SiteSetting = {
siteName: string;
phone?: string | null;
email?: string | null;
logo?: {
url: string;
alternativeText?: string | null;
} | null;
};
export async function getSiteSettings() {
const result = await strapiFetch<{
data: SiteSetting;
}>(
"/api/site-setting?populate[logo]=true",
);
return result.data;
}
在页面或服务端布局中调用:
import Link from "next/link";
import { getSiteSettings } from "@/lib/site-settings";
import { getMediaUrl } from "@/lib/projects";
export default async function SiteHeader() {
const settings = await getSiteSettings();
const logoUrl = getMediaUrl(
settings.logo?.url,
);
return (
<header className="border-b">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
<Link href="/" className="font-bold">
{logoUrl ? (
<img
src={logoUrl}
alt={
settings.logo?.alternativeText ??
settings.siteName
}
className="h-10 w-auto"
/>
) : (
settings.siteName
)}
</Link>
<div className="text-sm text-gray-600">
{settings.email}
</div>
</div>
</header>
);
}
注意:以上 API 路径以 Strapi 自动生成的默认 REST 路径为例。如果你修改过 API ID,需要以实际生成的接口路径为准。若全局设置读取失败,先检查 Single Type 的 API ID、发布状态和读取权限。
正式使用时,也可以将 Header 放进 app/layout.tsx,让它在整个网站中共享。
六、联系表单:从页面提交到 Strapi
这是本篇最重要的部分。
一个完整的联系表单至少需要经历以下步骤:
访客填写表单
↓
Next.js 客户端校验
↓
Next.js 服务端再次校验
↓
服务端使用 Token 请求 Strapi
↓
Contact Message 保存记录
↓
前端显示提交结果
为什么不让浏览器直接请求 Strapi?
因为浏览器中的请求和代码都可以被用户查看。如果将有写入权限的 API Token 放在前端,别人就可能拿它调用你的接口。
更安全的方案是由 Next.js 服务端接收表单,再使用服务器环境变量中的 Token 写入 Strapi。
1. 创建表单组件
创建 components/ContactForm.tsx:
"use client";
import { useState } from "react";
export default function ContactForm() {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
async function handleSubmit(
event: React.FormEvent<HTMLFormElement>,
) {
event.preventDefault();
setLoading(true);
setMessage("");
const form = event.currentTarget;
const formData = new FormData(form);
const payload = {
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
company: String(formData.get("company") ?? ""),
serviceInterest: String(
formData.get("serviceInterest") ?? "",
),
message: String(formData.get("message") ?? ""),
};
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) {
throw new Error(
result.error ?? "提交失败,请稍后再试。",
);
}
form.reset();
setMessage("提交成功,我们会尽快联系你。");
} catch (error) {
setMessage(
error instanceof Error
? error.message
: "网络异常,请稍后重试。",
);
} finally {
setLoading(false);
}
}
return (
<form
onSubmit={handleSubmit}
className="space-y-5"
>
<div>
<label
htmlFor="name"
className="mb-2 block text-sm font-medium"
>
姓名
</label>
<input
id="name"
name="name"
required
maxLength={100}
className="w-full rounded-lg border px-4 py-3"
/>
</div>
<div>
<label
htmlFor="email"
className="mb-2 block text-sm font-medium"
>
联系邮箱
</label>
<input
id="email"
name="email"
type="email"
required
maxLength={254}
className="w-full rounded-lg border px-4 py-3"
/>
</div>
<div>
<label
htmlFor="company"
className="mb-2 block text-sm font-medium"
>
公司名称(选填)
</label>
<input
id="company"
name="company"
maxLength={200}
className="w-full rounded-lg border px-4 py-3"
/>
</div>
<div>
<label
htmlFor="serviceInterest"
className="mb-2 block text-sm font-medium"
>
感兴趣的服务
</label>
<select
id="serviceInterest"
name="serviceInterest"
className="w-full rounded-lg border px-4 py-3"
>
<option value="">请选择</option>
<option value="branding">品牌设计</option>
<option value="space">空间设计</option>
<option value="website">网站设计</option>
</select>
</div>
<div>
<label
htmlFor="message"
className="mb-2 block text-sm font-medium"
>
项目需求
</label>
<textarea
id="message"
name="message"
required
minLength={10}
maxLength={5000}
rows={5}
className="w-full rounded-lg border px-4 py-3"
/>
</div>
<button
type="submit"
disabled={loading}
className="rounded-lg bg-black px-6 py-3 text-white disabled:opacity-50"
>
{loading ? "提交中..." : "提交需求"}
</button>
{message && (
<p role="status" aria-live="polite">
{message}
</p>
)}
</form>
);
}
这里的客户端校验主要是为了改善填写体验,并不能代替服务端校验。攻击者完全可以绕过网页,直接向接口发送请求。
2. 创建联系页面
在 app/contact/page.tsx 中:
import ContactForm from "@/components/ContactForm";
export default function ContactPage() {
return (
<main className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-4xl font-bold">
联系我们
</h1>
<p className="mt-4 text-gray-600">
告诉我们你的想法,一起把项目变成现实。
</p>
<div className="mt-10">
<ContactForm />
</div>
</main>
);
}
页面只负责展示表单。真正的写入逻辑放在 Next.js 的 Route Handler 中。
3. 服务端校验并写入 Strapi
创建 app/api/contact/route.ts:
import { NextResponse } from "next/server";
const STRAPI_URL = process.env.STRAPI_URL;
const STRAPI_API_TOKEN =
process.env.STRAPI_API_TOKEN;
type ContactPayload = {
name?: unknown;
email?: unknown;
company?: unknown;
serviceInterest?: unknown;
message?: unknown;
};
function getString(
value: unknown,
maxLength: number,
) {
if (typeof value !== "string") return "";
return value.trim().slice(0, maxLength);
}
export async function POST(request: Request) {
if (!STRAPI_URL || !STRAPI_API_TOKEN) {
return NextResponse.json(
{ error: "服务器配置不完整。" },
{ status: 500 },
);
}
let body: ContactPayload;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "请求格式不正确。" },
{ status: 400 },
);
}
const name = getString(body.name, 100);
const email = getString(body.email, 254);
const company = getString(body.company, 200);
const serviceInterest = getString(
body.serviceInterest,
100,
);
const message = getString(body.message, 5000);
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (
!name ||
!emailPattern.test(email) ||
message.length < 10
) {
return NextResponse.json(
{ error: "请检查姓名、邮箱和项目需求。" },
{ status: 400 },
);
}
try {
const response = await fetch(
new URL("/api/contact-messages", STRAPI_URL),
{
method: "POST",
headers: {
Authorization: `Bearer ${STRAPI_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
data: {
name,
email,
company: company || null,
serviceInterest: serviceInterest || null,
message,
status: "new",
},
}),
cache: "no-store",
},
);
if (!response.ok) {
// 详细错误只记录在服务端日志中。
console.error(
"Strapi contact submission failed:",
response.status,
);
return NextResponse.json(
{ error: "暂时无法提交,请稍后再试。" },
{ status: 502 },
);
}
return NextResponse.json(
{ success: true },
{ status: 201 },
);
} catch (error) {
console.error(
"Contact submission error:",
error,
);
return NextResponse.json(
{ error: "服务器暂时不可用,请稍后重试。" },
{ status: 502 },
);
}
}
这段代码实现了基本的服务端校验、Token 保护和错误处理。Strapi 5 的创建接口使用 data 包裹待写入字段。
上线前还必须补齐安全措施:
-
限制提交频率,避免接口被刷。
-
增加反垃圾机制,例如验证码或 Honeypot。
-
对请求体大小、字段格式和允许的服务类型进行严格校验。
-
只授予 API Token 创建联系记录所需的权限。
-
对外统一返回错误信息,避免泄露内部配置。
-
根据业务要求设置数据保留、访问控制和个人信息处理规则。
示例中的邮箱正则只用于基础格式检查,不代表邮箱真实存在。若需要更严格的校验,应结合实际业务实现。
4. 检查 Strapi 端权限和字段
回到 Strapi 后台,确认:
-
Contact Message的 API ID 实际为contact-message,对应接口通常是/api/contact-messages。 -
模型已经创建完成,字段名称和代码保持一致。
-
status的枚举值包含new。 -
服务端使用的 API Token 有创建联系记录的权限。
-
不要为了让前端提交成功,就把后台管理权限或不必要的读取权限全部开放给 Public 角色。
如果你在 Strapi 中修改过 API ID、字段名称或权限策略,请以实际配置为准。
七、从头到尾测试一次
建议按照下面的清单逐项验证:
-
Strapi 正常运行,测试项目已经发布。
-
Next.js 正常运行,环境变量配置正确。
-
打开
/projects,能看到项目列表和封面图片。 -
点击案例,能进入对应的详情页面。
-
修改 Strapi 中的项目摘要,确认缓存刷新后前端能看到变化。
-
打开
/contact,填写完整信息并提交。 -
在 Strapi 的 Content Manager 中找到新增的联系记录。
-
提交无效邮箱或过短的需求描述,服务端返回 400。
-
检查浏览器网络请求和页面源码,确认没有暴露 API Token。
开发期间可以将缓存时间设置得较短,方便验证;正式环境则应根据更新频率选择合理的缓存策略。如果需要发布后立即更新页面,可以进一步接入按需重新验证机制,而不是一味缩短所有页面的缓存时间。
八、常见问题排查
问题 1:接口返回 403 Forbidden
先检查 API Token 是否有效、权限是否足够,以及模型是否允许当前操作。不要直接开放所有权限来绕过错误。
问题 2:图片不显示
检查 Strapi 媒体 URL、图片域名白名单、populate 参数以及图片资源是否能从浏览器访问。远程图片使用 Next.js Image 时,还要确认 remotePatterns 配置正确。
问题 3:修改后台内容后前端没有变化
可能是缓存导致。检查 fetch 的缓存配置和重新验证策略,确认请求是否使用了旧数据。
问题 4:表单显示提交成功,但后台没有记录
检查服务端返回的 HTTP 状态码、Strapi 的创建权限、字段名称、API ID 和服务器日志。不要仅凭前端提示判断写入成功。
问题 5:开发环境能用,部署后失败
重点检查生产环境变量、Strapi 网络可达性、HTTPS 配置、API Token 权限,以及部署平台是否正确加载环境变量。
总结
到这里,设计公司官网的核心内容流转已经打通:
-
Strapi 管理网站设置、项目案例和文章。
-
Next.js 获取数据并渲染页面。
-
Tailwind CSS 负责页面布局和视觉样式。
-
联系表单通过服务端接口安全地写入 Strapi。
这套结构已经具备一个基础内容型官网的核心能力。接下来要做的,是把开发环境整理成可重复部署的项目,并完善数据库、反向代理、HTTPS、备份和生产安全配置。
下一篇:第 8 篇《二次开发 + 生产部署:Docker + PostgreSQL + Nginx 一把梭》