【Strapi 系列之七】从零做设计公司官网(二):Next.js 前端联调 + 表单提交,完整跑通内容闭环

上一期,我们已经在 Strapi 中创建了 8 个内容模型:网站设置、项目案例、服务、团队成员、文章、文章分类、客户评价和联系表单。

后台有了数据,接下来就要让网站真正跑起来。

这一篇使用 Strapi 5 + Next.js App Router + TypeScript + Tailwind CSS,完成三个目标:

  1. 从 Strapi 获取项目案例,并展示列表和详情。

  2. 获取网站全局设置,动态显示 Logo 和联系方式。

  3. 实现联系表单提交,将访客需求保存到 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 后台,确认:

  1. Contact Message 的 API ID 实际为 contact-message,对应接口通常是 /api/contact-messages。

  2. 模型已经创建完成,字段名称和代码保持一致。

  3. status 的枚举值包含 new。

  4. 服务端使用的 API Token 有创建联系记录的权限。

  5. 不要为了让前端提交成功,就把后台管理权限或不必要的读取权限全部开放给 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 一把梭》

相关推荐
guizhoumen6 小时前
CMS内容管理系统发展岔路口:WordPress生态化与PageAdmin平台化路线思辨
cms·网站建设·建站系统·pageadmin
Jack N3 天前
【Strapi 系列之八】二次开发 + 生产部署,Docker + PostgreSQL + Nginx 一把梭
nginx·docker·postgresql·strapi
能鈺CMS6 天前
CSDN-从27篇文档还原一套CMS开发规范
开源·cms·内容运营·源码下载·内容付费
Jack N6 天前
【Strapi 系列之六】从零做设计公司官网(一):Strapi 端 8 个模型这样建
cms·strapi
Jack N8 天前
【Strapi 系列之五】前端怎么接?Next.js + Tailwind 拉取 Strapi 数据(含代码)
开发语言·前端·javascript·strapi
凝新信息科技9 天前
Magnolia CMS 适合哪些企业?海外官网选型与适用场景指南
cms·内容管理系统·magnolia cms
喵个咪10 天前
GoWind CMS 拆解:在 Go 里给 TipTap 搭一套 Yjs 协同后端
后端·go·cms
喵个咪10 天前
GoWind CMS 拆解:社交分发引擎——10 平台适配器、状态闸门与自愈定时任务
后端·go·cms
Jack N12 天前
【Strapi 系列之三】中英双语怎么做?Strapi i18n 从配置到 API 取数
strapi