这篇是 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 接起来,核心其实只有三件事:
-
把 API 请求封装好,统一处理地址、参数、认证和错误。
-
把数据结构弄清楚 ,尤其是 Strapi 5 的响应格式、媒体 URL 和
populate。 -
根据业务选择渲染策略,在动态请求、缓存和静态生成之间找到平衡。
完成这一步后,你就已经有了一个可以持续扩展的内容型网站基础。
下一篇进入系列实战:从零做设计公司官网(一):Strapi 端 8 个模型这样建。 我们会把前面学到的建模、权限和 API 知识组合起来,设计一套真正能用于项目的内容结构。
【Strapi 系列之五】前端怎么接?Next.js + Tailwind 拉取 Strapi 数据(含代码)-CSDN博客