【Strapi 系列之五】前端怎么接?Next.js + Tailwind 拉取 Strapi 数据(含代码)

这篇是 Strapi 系列第 5 篇,进入真正的前后端集成实战:用 Next.js + Tailwind CSS + Strapi 5,把后台创建的文章、封面图片和详情内容展示到网站上。

这次不只讲概念,而是从项目配置、API 封装、列表页、详情页到图片 URL 拼接,提供一套可以直接改造的代码。也会把 populate、环境变量、服务端请求和 Next.js 静态生成这些常见问题串起来。

前端怎么接?Next.js + Tailwind 拉取 Strapi 数据(含代码)

Strapi 后台已经建好了 Article,文章也发布了,接下来怎么让网站真正显示出来?

很多开发者第一次接 Strapi 时,会卡在几个地方:接口能返回 JSON,但前端不知道怎么取字段;文章封面有数据,却显示不出来;列表页做好了,点击文章又不知道如何查询详情。

其实,Strapi 负责管理内容和提供 API,Next.js 负责页面渲染,Tailwind CSS 负责样式。只要把这三部分连接起来,就能搭出一个完整的内容网站。

这篇文章使用 Strapi 5 + Next.js App Router + TypeScript + Tailwind CSS ,实现文章列表、详情页、图片展示和静态路由生成。

一、先看整体架构

整个数据流很简单:

复制代码
Strapi 后台
    ↓ 创建、编辑、发布文章
Strapi REST API
    ↓ fetch 请求 JSON
Next.js Server Components
    ↓ 读取标题、正文、封面
Tailwind CSS
    ↓ 渲染页面
浏览器展示文章列表和详情

这里有一个重要的设计原则:公开内容尽量在 Next.js 服务端获取,不要把具有写入权限的 Strapi API Token 暴露给浏览器。

二、第一步:创建 Next.js 项目

如果还没有前端项目,可以使用以下命令:

复制代码
npx create-next-app@latest my-strapi-web
cd my-strapi-web
npm run dev

创建项目时,选择 TypeScript、App Router 和 Tailwind CSS 即可。当前版本的脚手架通常也支持直接使用默认配置。

安装查询参数工具 qs:

复制代码
npm install qs
npm install -D @types/qs

qs 可以帮助我们构造复杂的 Strapi REST API 查询参数,避免手动拼接 populate、filters 等长 URL。

项目结构可以先按下面的方式组织:

复制代码
my-strapi-web/
├── app/
│   ├── articles/
│   │   ├── [slug]/
│   │   │   └── page.tsx
│   │   └── page.tsx
│   ├── layout.tsx
│   └── page.tsx
├── lib/
│   ├── strapi.ts
│   └── articles.ts
├── .env.local
└── package.json

三、第二步:配置 Strapi 地址

在项目根目录创建 .env.local:

复制代码
STRAPI_URL=http://localhost:1337

如果 Next.js 和 Strapi 部署在不同服务器上,应替换为实际的 Strapi 服务端地址。

注意,这里使用的是 STRAPI_URL,而不是 NEXT_PUBLIC_STRAPI_URL。因为接下来通过服务端组件请求 Strapi,没有必要把内部 API 地址配置成公开的客户端变量。

环境变量修改后,建议重新启动 Next.js 开发服务器。

四、第三步:封装统一的 Strapi 请求

不要在每个页面里重复写 fetch、拼接 URL 和处理错误。我们先创建一个通用请求模块。

lib/strapi.ts:

复制代码
import qs from "qs";

const STRAPI_URL = process.env.STRAPI_URL;

if (!STRAPI_URL) {
  throw new Error("Missing STRAPI_URL");
}

export function getStrapiUrl(path: string) {
  return new URL(path, STRAPI_URL).toString();
}

export async function strapiFetch<T>(
  path: string,
  query?: Record<string, unknown>
): Promise<T> {
  const queryString = query
    ? qs.stringify(query, {
        encodeValuesOnly: true,
      })
    : "";

  const url = getStrapiUrl(
    `${path}${queryString ? `?${queryString}` : ""}`
  );

  const response = await fetch(url, {
    next: {
      revalidate: 60,
    },
  });

  if (!response.ok) {
    throw new Error(
      `Strapi request failed: ${response.status} ${response.statusText}`
    );
  }

  return response.json() as Promise<T>;
}

这里的 revalidate: 60 表示使用 Next.js 的缓存与重新验证机制,目标是让内容在适当的缓存周期后更新,而不是每次请求都重新访问 Strapi。

如果是需要实时显示的数据,可以根据业务调整缓存策略。开发阶段也可以暂时关闭缓存,方便调试。

另外,如果你的 Strapi API 需要认证,可以在这个服务端请求模块中统一添加 Authorization 请求头,并从服务端环境变量读取 Token。

五、第四步:定义文章类型并获取列表

假设 Strapi 中的 Article 模型包含以下字段:

字段 类型
title Text
slug UID
description Text
content Rich Text 或 Text
cover Media
publishedAt DateTime

Strapi 5 默认的 REST API 响应是扁平结构,字段通常直接位于内容对象中,不需要使用 Strapi 4 常见的 data.attributes.title 访问方式。<Cite refs={"turn657422search1"}/>

创建 lib/articles.ts:

复制代码
import { strapiFetch } from "./strapi";

export type StrapiMedia = {
  url: string;
  alternativeText?: string | null;
  width?: number;
  height?: number;
};

export type Article = {
  documentId: string;
  title: string;
  slug: string;
  description?: string | null;
  content?: unknown;
  publishedAt?: string | null;
  cover?: StrapiMedia | null;
};

type StrapiListResponse<T> = {
  data: T[];
  meta: {
    pagination: {
      page: number;
      pageSize: number;
      pageCount: number;
      total: number;
    };
  };
};

export async function getArticles() {
  return strapiFetch<StrapiListResponse<Article>>(
    "/api/articles",
    {
      sort: ["publishedAt:desc"],
      pagination: {
        page: 1,
        pageSize: 10,
      },
      fields: [
        "title",
        "slug",
        "description",
        "publishedAt",
      ],
      populate: {
        cover: {
          fields: [
            "url",
            "alternativeText",
            "width",
            "height",
          ],
        },
      },
    }
  );
}

这里有两个重点。

第一,populate 用来获取封面媒体数据。Strapi 默认不会自动返回所有媒体、关联内容、Component 和 Dynamic Zone,需要根据实际字段主动配置。<Cite refs={"turn657422search2","turn657422search5"}/>

第二,fields 用来限制普通字段的返回范围,而 populate 负责额外获取媒体等关联结构。两者可以配合使用。

上述类型是根据示例模型定义的,实际项目需要按照自己的字段调整。

六、第五步:实现文章列表页

创建 app/articles/page.tsx:

复制代码
import Link from "next/link";
import Image from "next/image";
import { getArticles } from "@/lib/articles";
import { getStrapiUrl } from "@/lib/strapi";

export default async function ArticlesPage() {
  const result = await getArticles();

  return (
    <main className="mx-auto max-w-6xl px-6 py-12">
      <h1 className="mb-8 text-3xl font-bold">
        技术文章
      </h1>

      <div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
        {result.data.map((article) => {
          const coverUrl = article.cover?.url
            ? getStrapiUrl(article.cover.url)
            : null;

          return (
            <article
              key={article.documentId}
              className="overflow-hidden rounded-xl border border-gray-200 bg-white transition hover:shadow-lg"
            >
              {coverUrl && (
                <Link href={`/articles/${article.slug}`}>
                  <Image
                    src={coverUrl}
                    alt={article.cover?.alternativeText ?? article.title}
                    width={article.cover?.width ?? 1200}
                    height={article.cover?.height ?? 800}
                    className="h-52 w-full object-cover"
                  />
                </Link>
              )}

              <div className="p-5">
                <h2 className="mb-3 text-xl font-semibold">
                  <Link
                    href={`/articles/${article.slug}`}
                    className="hover:text-blue-600"
                  >
                    {article.title}
                  </Link>
                </h2>

                <p className="line-clamp-3 text-sm leading-6 text-gray-600">
                  {article.description}
                </p>

                <Link
                  href={`/articles/${article.slug}`}
                  className="mt-4 inline-block text-sm font-medium text-blue-600"
                >
                  阅读全文 →
                </Link>
              </div>
            </article>
          );
        })}
      </div>
    </main>
  );
}

这样,文章列表就可以从 Strapi 获取真实数据,并通过 Tailwind CSS 渲染成响应式卡片。

图片为什么需要拼接 URL?

Strapi 的媒体字段通常返回类似 /uploads/cover_xxx.jpg 的相对路径。

Next.js 前端需要把它转换成完整地址:

复制代码
const imageUrl = getStrapiUrl("/uploads/cover_xxx.jpg");

如果 Strapi 返回的本身已经是完整 URL,则不应该再次拼接。上面的示例假设媒体 URL 是相对路径。

如果使用 next/image,还要在 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。

七、第六步:实现文章详情页

列表页做好后,还需要通过 Slug 获取单篇文章。

我们在 lib/articles.ts 中增加:

复制代码
export async function getArticleBySlug(slug: string) {
  const result = await strapiFetch<{
    data: Article[];
  }>("/api/articles", {
    filters: {
      slug: {
        $eq: slug,
      },
    },
    populate: {
      cover: {
        fields: [
          "url",
          "alternativeText",
          "width",
          "height",
        ],
      },
    },
  });

  return result.data[0] ?? null;
}

这里通过 filters[slug][$eq] 查找指定文章。由于 Slug 是内容标识,最好在模型设计和内容维护时保证其唯一性。

接着创建 app/articles/[slug]/page.tsx:

复制代码
import Image from "next/image";
import { notFound } from "next/navigation";
import {
  getArticleBySlug,
  getArticles,
} from "@/lib/articles";
import { getStrapiUrl } from "@/lib/strapi";

type Props = {
  params: Promise<{ slug: string }>;
};

export async function generateStaticParams() {
  const result = await getArticles();

  return result.data.map((article) => ({
    slug: article.slug,
  }));
}

export default async function ArticleDetailPage({
  params,
}: Props) {
  const { slug } = await params;
  const article = await getArticleBySlug(slug);

  if (!article) {
    notFound();
  }

  const coverUrl = article.cover?.url
    ? getStrapiUrl(article.cover.url)
    : null;

  return (
    <main className="mx-auto max-w-3xl px-6 py-12">
      <h1 className="mb-6 text-4xl font-bold leading-tight">
        {article.title}
      </h1>

      {coverUrl && (
        <Image
          src={coverUrl}
          alt={article.cover?.alternativeText ?? article.title}
          width={article.cover?.width ?? 1200}
          height={article.cover?.height ?? 800}
          className="mb-8 h-auto w-full rounded-xl object-cover"
          priority
        />
      )}

      {article.description && (
        <p className="mb-8 text-lg leading-8 text-gray-600">
          {article.description}
        </p>
      )}

      <div className="whitespace-pre-wrap leading-8 text-gray-800">
        {typeof article.content === "string"
          ? article.content
          : null}
      </div>
    </main>
  );
}

这里使用了 Next.js App Router 的动态路由,以及 generateStaticParams() 在构建时预生成一部分详情页。<Cite refs={"turn657422search0"}/>

注意:这个示例为了突出数据获取,暂时只渲染纯文本正文。如果 Strapi 中的 content 是 Rich Text 编辑器产生的结构化数据,或者使用 Dynamic Zone,需要根据实际返回格式编写对应的渲染组件,不能直接把任意 JSON 当成字符串展示。

另外,generateStaticParams() 示例只获取了当前列表请求返回的文章。文章数量较多时,可以单独实现获取全部 Slug 的函数,并根据项目规模决定哪些页面需要在构建时生成。

八、常见问题排查

问题 解决方向
API 返回 403 检查 Public 的 find 权限或服务端 Token
文章列表为空 检查文章是否发布、API ID、筛选条件和 Locale
图片没有返回 检查是否正确设置 populate
图片返回了但无法显示 检查媒体 URL 是否完整、远程图片配置是否正确
正文是 JSON 或无法显示 检查 Rich Text 类型,按实际格式渲染
修改内容后前端没有更新 检查 Next.js 缓存与重新验证策略
详情页提示找不到文章 检查 Slug、发布状态和查询结果

还要注意:公开文章接口可以使用 Public 只读权限;如果需要 Token,应该将它保存在服务端环境变量中,避免进入浏览器代码。

九、上线前:缓存与静态生成怎么选?

Next.js 与 Strapi 的组合有两种常见思路。

方案 A:服务端动态获取。

适合需要较及时反映内容变化、或者内容高度个性化的页面。根据 Next.js 版本与缓存配置决定是否缓存请求。

方案 B:静态生成 + 定时重新验证。

适合公司官网、博客、作品集等内容变化不频繁的站点。页面可以在构建时生成,并通过 revalidate 或按需重新验证机制更新。

对于内容型官网,可以先采用静态生成和合理缓存,后续再按实际访问量、更新频率和部署环境调整。

如果希望发布文章后立即更新页面,还可以进一步配置 Strapi Webhook,由服务端触发 Next.js 的按需重新验证。

写在最后

把 Strapi 和 Next.js 接起来,核心其实只有三件事:

  1. 把 API 请求封装好,统一处理地址、参数、认证和错误。

  2. 把数据结构弄清楚 ,尤其是 Strapi 5 的响应格式、媒体 URL 和 populate。

  3. 根据业务选择渲染策略,在动态请求、缓存和静态生成之间找到平衡。

完成这一步后,你就已经有了一个可以持续扩展的内容型网站基础。

下一篇进入系列实战:从零做设计公司官网(一):Strapi 端 8 个模型这样建。 我们会把前面学到的建模、权限和 API 知识组合起来,设计一套真正能用于项目的内容结构。

【Strapi 系列之五】前端怎么接?Next.js + Tailwind 拉取 Strapi 数据(含代码)-CSDN博客

相关推荐
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY85
前端
来一首_We_go1 小时前
React Flow Pro 高级交互实现
前端·react.js
weedsfly1 小时前
React 不可变数据:为什么页面“明明数据变了却不更新
前端·javascript·react.js
行者全栈架构师1 小时前
【共创稿事节】双引擎智能相册:HarmonyOS 7(API 26)文搜图 × 图像超分端侧 AI 联动实战
前端·算法·架构
chromoany1 小时前
agent 写的 README 为什么装不上:一条“没有出处就删掉”的规则
前端
deli0071 小时前
商品条形码最后一位怎么来的?Code128 校验位实测
前端
Gizzap_Tech1 小时前
双语内容审阅台怎么做?用内容指纹保留核验记录
javascript
小蒜学长1 小时前
基于SpingBoot+Vue的定向越野赛事管理系统的设计与开发(代码+数据库+LW)
java·前端·spring boot·后端·前后端分离·定向越野赛事
nyaomaru1 小时前
is-kit 达成 100 Stars ⭐🎉 从 50 到 100,发生了什么?
前端·typescript