除本站自媒体官号以外,本文的内容禁止任一组织、个人、账号或平台,以任何形式转载到 RainyDesign.cn 站外。
任何个人或组织进行非法转载的,本站工作人员可对其追究法律责任。
具体信息详见 条例与条款 。
Next.JS + Strapi + Tailwind CSS 全套网建实战教学 —— 细节完善
进一步优化项目视效、细节反馈与交互逻辑,构建对开发者与用户更友好的项目实例。
章节回顾
上一个章节我们已经实现了项目的样式与布局,但离完成整个可用的商业项目还需完善一些细节,现在就让我们动手完善一下:
-
网站部署 (撰写中)
网站部署:Cloudflare、Vercel或租赁云服务器(仍在撰写)
网站部署:注册域名 + SSL证书申请(仍在撰写)
SEO:搜索引擎提交收录(仍在撰写)
-
资源与教程项目包下载
点击此处下载,最后更新于:2026 年 5 月 26 日。为获取良好的学习体验,请务必以最新版本为主! - FAQ(常见疑问解答)
五、项目构建
5.1 React.JS (JSX) :使用 ReactMarkdown 插件美化正文
现在让我们来安装 ReactMarkdown 插件,以按需实现以下功能:
- Remark-GFM:自动链接文字、尾注、删除线、表格等
- Remark heading Id:自定义标题 ID
- React syntax highlighter:代码块高亮
- React copy to clipboard:代码块一键复制
首先,我们需要访问 Strapi 中的主页页面,然后将 codeblockContent 文字域中的内容替换为下列内容:
代码已复制!
```jsx ... return { <> <h2 className="uppercase text-5xl font-medium”>{data.attributes.title}</h2> <p className="font-semibold text-blue-500”>Hold tight!</p> <p>We’re racing ahead with cutting-edge web technologies to lead the industry.</p> </> } ... ```
保存并发布后,停止 Next.JS 项目并安装上述插件:
代码已复制!
# 以下为终端命令行 yarn add remark-gfm # 安装 Remark-GFM yarn add remark-heading-id # 安装 Remark heading ID yarn add react-syntax-highlighter # 安装 React syntax highlighter yarn add react-copy-to-clipboard # 安装 React copy to clipboard # 上述依赖均已安装完毕后,清除缓存并重启项目 rm -rf .next yarn dev
接着编辑 markdown.js :
代码已复制!
// Import dependencies import React from 'react' import ReactMarkdown from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import remarkHeadingId from 'remark-heading-id' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { duotoneLight } from 'react-syntax-highlighter/dist/esm/styles/prism' import { CopyToClipboard } from 'react-copy-to-clipboard' export default function Markdown({ data, position }) { if (!data) return null const myCustomTheme = { "introduction": { showLineNumbers: false, margin: 'none', background: 'none', padding: 'none', ...duotoneLight, 'pre[class*="language-"]': { ...duotoneLight['pre[class*="language-"]'], background: 'none', padding: '0px' }, 'code[class*="language-"]': { ...duotoneLight['code[class*="language-"]'], background: 'none' } } }; return ( <ReactMarkdown remarkPlugins={[remarkGfm, remarkHeadingId]} rehypePlugins={[rehypeRaw]} children={data} components={{ code(props) { const { children, className, node, ...rest } = props const match = /language-(\w+)/.exec(className || '') return match ? ( <div className='CodeBlock'> <button className='CopyButton absolute right-2 top-4 size-9 p-2.5 rounded-md bg-slate-50 opacity-50 transition duration-300 hover:opacity-100'> <CopyToClipboard text={String(children)} onCopy={() => alert("Codeblock copied!")}> <span className="icon icon-nav-list-order"></span> </CopyToClipboard> </button> <SyntaxHighlighter {...rest} PreTag="div" children={String(children).replace(/\n$/, '')} language={match[1]} style={position ? myCustomTheme[position] : duotoneLight} showLineNumbers={position ? myCustomTheme[position].showLineNumbers : false} wrapLines={false} /> </div> ) : ( <code {...rest} className={className}> {children} </code> ) } }} /> ) }
然后修改 Introduction.js 的第 25 行 至第 27 行以传入 position:
代码已复制!
... <div className="relative mt-10 overflow-x-scroll hide-scrollbar"> <Markdown data={data.codeblockContent} position="introduction"/> </div> ...
由此可见,我们通过一个在 <Markdown /> 标签中添加的 position=* 接口与数据,以控制该插件采用不同的样式,并切换是否显示代码行计数。接下来让我们继续博客文章的样式开发:
http://127.0.0.1:3000/blog/a-bug-is-becoming-a-meme-on-the-internet
点进去可以看到文章铺满了整个页面而且没有设定样式。我们直接上添加了原子类名与格式化日期的代码,此外添加了 formatDate ,注意 DOM 树也有些许变化:
代码已复制!
// Import libraries import { fetcher } from '@/lib/api' import formatDate from '@/lib/formatDate' // Import Next components import Image from 'next/image' import Link from 'next/link' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import ArticleDynamicZone from '@/components/article/ArticleDynamicZone' export default function BlogArticle({ globalData, data }) { const date = formatDate(data.publishedAt) return ( <> <CustomizedHead globalData={globalData} data={data.seo} /> <div> <Image src={`${process.env.NEXT_PUBLIC_API_URL}${data.cover.url}`} alt={`${data.cover.alternativeText}${globalData.globalAddOn}`} width={data.cover.width} height={data.cover.height} loading="lazy" className="max-h-screen object-cover object-center lg:max-h-[75vh]"/> <div className="customized-container mt-24 space-y-6"> <h1 className="text-3xl font-medium uppercase lg:text-4xl xl:text-5xl">{data.title}</h1> <Link href={`/category/${data.category.slug}`} className="inline-block px-3 py-2 rounded-full border border-primary">{data.category.name}</Link> <p> {data.author.name}, <span className="text-secondary">{date}</span> </p> </div> </div> <div className="customized-container mt-6"> <section> <ArticleDynamicZone globalData={globalData} data={data.blocks} /> </section> </div> </> ) } export async function getStaticPaths() { const response = await fetcher(`articles`) const paths = response.data.map((article) => ({ params: { slug: [article.slug] }, })) return { paths, fallback: false } } export async function getStaticProps({ params }) { const slug = params.slug[0] const response = await fetcher(`articles?filters[slug][$eq]=${slug}&populate[0]=author&populate[1]=category&populate[2]=cover&populate[3]=seo&populate[4]=blocks&populate[5]=blocks.file&populate[6]=blocks.files`) return { props: { data: response.data[0] } } }
最后,为 ArticleDynamicZone.js 添加内容:
代码已复制!
// Import Next components import Image from 'next/image' // Import libraries import Markdown from '@/lib/markdown' export default function ArticleDynamicZone({ globalData, data }) { const renderComponent = (component, index) => { // 定义 renderComponent 函数,传入 component 和 index 参数 switch (component.__component) { // 为传入的动态组件数据建立 switch 条件判断 case 'shared.rich-text' : // 类型为 rich-text 时执行 Markdown 渲染 return ( <Markdown data={component.body} key={`rich-text-${component.id}-${index}`}/> ) case 'shared.quote': // 类型为 quote 时渲染 <blockquote> 及其中的 <p> 与 <cite> 标签 return ( <blockquote key={`quote-${component.id}-${index}`}> <p>"{component.body}"</p> {component.title && ( <cite >— {component.title}</cite> )} </blockquote> ) case 'shared.media': // 类型为 media 时渲染图片或视频,并自动添加 caption 文本 return ( <div key={`media-${component.id}-${index}`}> <div> {component.file && // 通过 files.mime 的开头字段判断文件类型:图片或视频 component.file.mime?.startsWith('image/') ? ( // 渲染图片 <Image key={`media-${component.id}-${index}`} src={`${process.env.NEXT_PUBLIC_API_URL}${component.file.url}`} alt={`${component.file.alternativeText}${globalData.globalAddOn}`} width={component.file.width} height={component.file.height} /> ) : component.file.mime?.startsWith('video/') ? ( // 渲染视频;本段代码还需要实际上传视频做测试 <video controls width={component.file.width} height={component.file.height} poster={component.file.previewUrl ? `${process.env.NEXT_PUBLIC_API_URL}${component.file.previewUrl}` : undefined}> <source src={`${process.env.NEXT_PUBLIC_API_URL}${component.file.url}`} type={component.file.mime} /> </video> ) : ''} </div> {component.file.caption && ( <p className="caption">{component.file.caption}</p> )} </div> ) case 'shared.slider': // 类型为 sliders 时渲染一组图片 return ( component.files.length > 0 ? ( <div key={`slider-${component.id}-${index}`} > <div> {component.files.map((file, fileIndex) => ( <Image key={`slider-item-${fileIndex}`} src={`${process.env.NEXT_PUBLIC_API_URL}${file.url}`} alt={`${file.alternativeText} - Slider image ${fileIndex + 1}${globalData.globalAddOn}`} width={file.width} height={file.height}/> ))} </div> <p className="caption">Slide to view images >></p> </div> ): '' ) default: // 默认情况下在打印台警告一个未知组件,并在 HTML 中渲染未知组件信息 console.warn(`Unknown component type: ${component.__component}`) return ( <div key={`unknown-${index}`}> <p>Unknown component: {component.__component}</p> </div> ) } } return ( // 渲染 Dynamic Zone 组件列表 <> {data.map((component, index) => renderComponent(component, index))} </> ) }
再次刷新页面就可以看到正文样式全部修缮完毕了。
5.2 Strapi + Next.JS + JSX:博客文章搜索
接下来我们需要使用 Strapi 文档列表 API 中的 meta - pagination 参数实现数据分页,并借助 Next navigation 模块实现搜索功能与 URL 状态的切换;小节内容比较长,所以我们拆分为搜索与分页两个部分,先实现搜索功能。
让我们回到 Blog 页面,为该组件加好 position 以做位置判断:
代码已复制!
// Import library import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' import FeaturedArticles from '@/components/blog/FeaturedArticles' import ArticleCardContainer from '@/components/shared/ArticleCardContainer' export default function Blog ({ globalData, data, latestArticles }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo}/> <Information globalData={globalData} data={data.banner} position="blog"/> <FeaturedArticles globalData={globalData} articlesOfTheDay={data.articlesOfTheDay} articlesOfTheDay2={data.articlesOfTheDay2} latestArticles={latestArticles} /> <ArticleCardContainer globalData={globalData} data={data.featuredCategories} position="blog"/> {/* 在这里传入页面位置 */} </> ) } export async function getStaticProps() { const response = await fetcher(`blog?populate[0]=articlesOfTheDay.author&populate[1]=articlesOfTheDay.category&populate[2]=articlesOfTheDay.cover&populate[3]=articlesOfTheDay2.author&populate[4]=articlesOfTheDay2.category&populate[5]=articlesOfTheDay2.cover&populate[6]=banner.image&populate[7]=featuredCategories.articles.cover&populate[8]=featuredCategories.articles.author&populate[9]=seo`) const latestArticles = await fetcher(`articles?sort=publishedAt:desc&populate=*&pagination[limit]=3`) return { props: { data: response.data, latestArticles: latestArticles.data } } }
接下来我们来实现搜索功能。首先是对 Information.js 的改造,使其对应 Blog 页面渲染搜索框而非点击按钮:
代码已复制!
// Import Next component import Link from 'next/link' import Image from 'next/image' // Import components import Search from './Search' export default function Information({ globalData, data, position }) { const backgroundClasses = { "course": "bg-[url('/image/shared/information_course_bg.png')]", "blog": "bg-[url('/image/shared/information_blog_bg.png')]", "about": "bg-[url('/image/shared/information_about_bg.png')]", "search": "bg-[url('/image/shared/information_search_bg.png')]" // 新增背景,需要创建对应的图片在对应目录下 } return ( <div className={`relative flex items-center w-full h-screen px-6 pt-6 mask-b-from-67% bg-cover bg-center bg-no-repeat md:px-9 md:pt-9 lg:max-h-180 lg:px-18 lg:pt-18 2xl:px-[calc(50%-43.5rem)] ${backgroundClasses[position]}`}> <div className="relative max-w-120"> {/* 判断所处的页面位置,于 About 、 Blog 与 Course 页面时,为标题增设 uppercase 原子类名 */} <h1 className={`text-3xl font-medium lg:text-5xl ${(position === 'about' || position === 'blog' || position === 'course') ? 'uppercase' : ''}`}>{data.heading}</h1> <p className="mt-6">{data.description}</p> {/* 为博客、文章搜索与文章目录页时渲染搜索框,否则渲染链接 */} {position === "blog" || position === "blog-search" || position === "category" ? <Search globalData={globalData} /> : <Link href={data.buttonLink} {...(data.openInNewTab && { target: '_blank' })} className="customized-link-button mt-9"> {data.buttonText} <span></span> </Link> } </div> </div> ) }
可见此处新增了一个 Search.js 组件,我们需要在对应的目录下创建好:
代码已复制!
// Import React hooks import { useState, useEffect } from "react" // Import Next components import { useRouter } from "next/router" export default function Search({ globalData }) { const router = useRouter() // 使用 React hook useState 定义搜索内容状态与内容更新函数 const [searchContent, setSearchContent] = useState('') const handleFormSubmit = (e) => { e.preventDefault() const trimmedSearch = searchContent.trim() if (!trimmedSearch) { // 如搜索内容为空则调用清除逻辑 handleClearSearch() return } // trimmedSearch 可用时跳转到搜索页面 router.push(`/blog/search?q=${encodeURIComponent(trimmedSearch)}&page=1`) } // 封装清除逻辑,使其便于调用 const handleClearSearch = () => { // 清除搜索内容 setSearchContent('') // 返回博客首页 router.push('/blog') } return ( <form onSubmit={handleFormSubmit} className="relative inline-block mt-9"> <span className="customized-shadow"></span> <span className="absolute w-[calc(100%-.25rem)] h-[calc(100%-.3125rem)] left-0.5 top-0.5 rounded-full bg-linear-to-b from-slate-300 to-slate-100"></span> {/* 搜索框,内容变更同步提交到 searchContent */} <input name="search" value={searchContent} onChange={(e) => setSearchContent(e.target.value)} placeholder="Search content here" className="relative h-13 min-w-72 rounded-full border-white border-2 border-b-3 pl-6 pr-17 focus:outline-slate-400" /> {/* 清除按钮,当 searchContent 不为空时渲染 */} {searchContent && (<button type="button" onClick={handleClearSearch} className="absolute top-1/2 right-15 -translate-y-1/2 text-gray-400 hover:text-gray-600 z-10" aria-label="Clear search content">✕</button> )} <button type="submit" className="absolute aspect-square h-[calc(100%-.25rem)] top-0.5 right-0.5 p-3.5 rounded-full bg-radial-[at_75%_25%] from-[#bdcde1] to-[#9fb5d6] hover:from-[#a8bdd4] hover:to-[#8da8ca] transition-colors cursor-pointer" aria-label="Search"> <span className="icon icon-form-search block brightness-500"></span> </button> </form> ) }
我们会发现搜索提交会将页面跳转到 /blog/search 路由,所以我们需要创建一个 search.js 页面于 @/pages/blog/ 目录下:
代码已复制!
// Import React hooks import { useState, useEffect, useCallback } from 'react' import { useRouter } from 'next/router' // Import library import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' import ArticleCardContainer from '@/components/shared/ArticleCardContainer' import Link from 'next/link' // Set-up per page variants const pageSize = 12 // 设定分页参数为 12 ,即每页最多 12 个 export default function BlogSearch({ globalData, searchResults, searchContent, pageIndex }) { const router = useRouter() const { q: urlSearch, page: urlPage } = router.query // 设定搜索参数与路由 const [error, setError] = useState(null) // 初始化错误内容 const [data, setData] = useState(searchResults.data) // 将搜索结果中的数据映射到 data const [meta, setMeta] = useState(searchResults.meta) // 将搜索结果中的分页信息映射到 meta const [isLoading, setIsLoading] = useState(false) // 初始化加载状态为否 const [searchTerm, setSearchTerm] = useState(searchContent) // 初始化搜索内容 const [currentPage, setCurrentPage] = useState(pageIndex) // 初始化页码 // 获取搜索结果 const fetchSearchResults = useCallback(async (searchContent, pageIndex) => { // 检查是否填写了搜索内容 if (!searchContent) return // 检查 pageIndex 是否为有效的正整数 if (!Number.isInteger(pageIndex) || pageIndex < 1) { console.error('Invalid page index:', pageIndex) return } // 启用加载状态 setIsLoading(true) try { // 获取数据 const data = await fetcher( `articles?filters[title][$containsi]=${encodeURIComponent(searchContent)}&sort[0]=createdAt:desc&fields[0]=slug&fields[1]=title&populate[0]=cover&populate[1]=author&populate[2]=category&pagination[page]=${pageIndex}&pagination[pageSize]=${pageSize}` ) // 加载数据 setData(data.data) setMeta(data.meta) // 清空错误信息 setError(null) } catch (error) { console.error('Failed to fetch search results:', error) // 控制台打印错误 setError('Failed to load search results. Please try again.') // 提示搜索错误 // 清空数据 setData([]) setMeta([]) } finally { setIsLoading(false) } }, []) // 监听 URL 变化,重新获取数据 useEffect(() => { // 如果 URL 参数不存在,不执行任何操作(使用服务端数据) if (!urlSearch || !urlPage) return const newSearchTerm = urlSearch const newPage = parseInt(urlPage, 10) // 检查是否与当前状态相同,避免重复请求 if (newSearchTerm === searchTerm && newPage === currentPage) return // 验证页码有效性 if (!Number.isInteger(newPage) || newPage < 1) { // 重定向到第一页 router.replace(`/blog/search?q=${encodeURIComponent(newSearchTerm)}&page=1`) return } // 更新状态并获取数据 setSearchTerm(newSearchTerm) setCurrentPage(newPage) // 将 (newSearchTerm, newPage) 作为参数传入 fetchSearchResults(searchContent, pageIndex) fetchSearchResults(newSearchTerm, newPage) }, [urlSearch, urlPage, searchTerm, currentPage, router, fetchSearchResults]) // 移除依赖以避免循环 // 处理搜索结果信息,传入 Information 组件 const searchInformation = ( searchResults.error ? { "heading": "No articles found", "description": `No articles found for "${searchContent}", try other keywords` } : error ? { "heading": "Search failed", "description": error } : { "heading": `Search results of "${searchContent}"`, "description": `Found ${meta.pagination.total} articles in ${meta.pagination.pageCount} pages` } ) // 处理分页动态路由,传入 ArticleCardContainer 组件 const handlePageChange = (newPage) => { const params = new URLSearchParams() params.set('q', searchTerm) params.set('page', newPage.toString()) router.push(`/blog/search?${params.toString()}`, undefined, { shallow: true }) } // 处理搜索结果数据,传入 ArticleCardContainer 组件 const searchData = { data, meta, name: isLoading ? "Searching, please wait" : "Search results", onPageChange: handlePageChange } // 自定义 SEO const searchSeo = { metaTitle: `Search Results of "${searchTerm}"`, metaDescription: `Browse ${meta.pagination.total || 'all'} articles related to "${searchTerm}". Find tutorials, guides, and insights on "${searchTerm}" and related topics.`, metaRobots: 'noindex, follow', canonicalUrl: `/blog/search?q=${encodeURIComponent(searchTerm)}&page=${currentPage}` } return ( <> <CustomizedHead globalData={globalData} data={searchSeo}/> <Information globalData={globalData} data={searchInformation} position="blog-search" /> <ArticleCardContainer globalData={globalData} data={searchData} position="blog-search" /> </> ) } export async function getServerSideProps({ query }) { // 初始化搜索内容与页码 const { q: searchContent = '', page = '1' } = query // 解析页码 const pageIndex = parseInt(page, 10) // 如果没有搜索词,重定向到博客首页 if (!searchContent) { return { redirect: { destination: '/blog', permanent: false, }, } } // 验证页码,如果无效则重定向到第一页 if (!Number.isInteger(pageIndex) || pageIndex < 1) { return { redirect: { destination: `/blog/search?q=${encodeURIComponent(searchContent)}&page=1`, permanent: false, }, } } // 获取搜索结果 const searchResults = await fetcher( `articles?filters[title][$containsi]=${encodeURIComponent(searchContent)}&sort[0]=createdAt:desc&fields[0]=slug&fields[1]=title&populate[0]=cover&populate[1]=author&populate[2]=category&pagination[page]=${pageIndex}&pagination[pageSize]=${pageSize}` ) return { props: { searchResults, searchContent, pageIndex } } }
其他内容具体可以看注释,解释得很清楚;这些传入的数据也为下个小节对应的分页功能做好了铺垫,现在修改 ArticleCardContainer.js 的内容即可实现搜索功能:
代码已复制!
// Import Next components import Link from 'next/link' // Import components import ArticleCard from '@/components/shared/ArticleCard' export default function ArticleCardContainer ({ globalData, data, position }){ // 封装渲染逻辑 const renderCategory = (category, categoryIndex) => { // 根据页面类型执行数组判断 const articles = position === "blog" ? category.articles : category.data // 设定位于 Blog 与 Category 下的 tag.name 与 tag.slug,因为它们都是相同的,不需要在 Article 里头拿数据 const tag = position !== "blog-search" ? { name: category.name, slug: category.slug } : null // 检测数据数组是否有长度,有的话渲染内容;如数组无长度且当前分页超出最大页码,则直接渲染错误提示 return articles.length ? ( <div key={categoryIndex} className="space-y-6"> <div className="px-6 mx-6 max-lg:space-y-3 md:px-9 lg:flex lg:justify-between lg:items-center lg:px-0"> <h3 className="text-2xl capitalize">{category.name}</h3> {/* 判断是否位于博客文章搜索页或文章目录页,是就显示文章基数,否则一律为 View all 链接,点击跳转至目录 */} {(position === "blog-search" || position === "category") ? <p>{category.meta.pagination.total} Articles</p> : <Link href={`/category/${category.slug}`}>View all >></Link>} </div> <div className="max-lg:overflow-x-scroll hide-scrollbar"> <div className={`gap-6 px-6 md:grid md:grid-cols-2 md:px-9 lg:px-0 xl:grid-cols-3 ${position === 'latest' && 'max-lg:w-240'}`}> {articles && articles.map((articleCard, articleCardIndex) => { {/* 如果在搜索页面则从数组循环中为各个文章定义 searchArticlesTag ,否则使用页面内的 Tag */} const searchArticlesTag = position === "blog-search" && { name: articleCard.category.name, slug: articleCard.category.slug } return (<ArticleCard key={articleCardIndex} globalData={globalData} data={articleCard} tag={searchArticlesTag ? searchArticlesTag : tag}/> ) })} </div> </div> </div> ) : <p>Invalid page count, please input correct value.</p> } // 执行渲染 return ( <div className="mt-30 space-y-30 lg:max-w-8xl lg:px-18 lg:mx-auto"> {/* 判断页面位置,除了博客页面有多个 category 需要渲染,其他页面只取单条 data */} {position === "blog" ? data && data.map((category, categoryIndex) => renderCategory(category, categoryIndex)) : renderCategory(data, 0) } </div> ) }
搜索页面的具体路由为:/blog/search?q=${searchContent}&page=${pageIndex} ,如 http://127.0.0.1:3000/blog/search?q=i&page=1 ;
- Q:如果输入的页码是负数,或者数据有误怎么办?
- 如果是负数或者错误的页码,会在服务端(
getServerSideProps)被重定向到page=1,确保不会有无效数据传入组件; - 如果
articles.length的结果为false,则会在ArticleCardContainer.js中渲染错误提示:<p>Invalid page count, please input correct value.</p>。
- 如果是负数或者错误的页码,会在服务端(
此外,我们可以看到搜索页面传入了 metaRobots 与 canonicalUrl 两个新的 meta 数据,所以我们可以按需修改一下 CustomizedHead.js 组件以传入 robots ,以实现更好的 SEO 效果 :
代码已复制!
// Import Next components import Head from 'next/head' export default function CustomizedHead({ globalData, data }) { const seo = { metaTitle: data?.metaTitle + globalData.globalAddOn || globalData.defaultSeo.metaTitle + globalData.globalAddOn, metaDescription: data?.metaDescription || globalData.defaultSeo.metaDescription, shareImage: data?.shareImage?.url || globalData.defaultSeo.shareImage.url, metaRobots: data?.metaRobots || null, canonicalUrl: data?.canonicalUrl || null } return ( <Head> <title>{seo.metaTitle}</title> <meta name="description" content={seo.metaDescription} /> <meta property="og:title" content={seo.metaTitle} /> <meta property="og:description" content={seo.metaDescription} /> <meta property="og:image" content={`${process.env.NEXT_PUBLIC_API_URL}${seo.shareImage}`} /> {seo.metaRobots && <meta name="robots" content={seo.metaRobots} /> } {seo.canonicalUrl && <link rel="canonical" href={seo.canonicalUrl} /> } </Head> ) }
对应的 category 页面也需要调整一下:
代码已复制!
... // 自定义 SEO const categorySeo = { metaTitle: `Articles of "${slug}"`, metaDescription: `Browse ${pagination.total || 'all'} articles in "${slug}" category. Find tutorials, guides, and insights on "${slug}" and related topics across ${pagination.pageCount} pages.`, metaRobots: 'index, follow', canonicalUrl: `/category/${slug}?page=${page}` } return ( <> <CustomizedHead globalData={globalData} data={categorySeo} /> <Information globalData={globalData} data={categoryInformation} position="category" /> <ArticleCardContainer globalData={globalData} data={categoryData} position="category"/> </> ) ...
- Q:为什么 category 与 search 页面在 Strapi 中没有对应的单页文档?
- A:前者并不是必要的,而后者甚至都不需要做 SEO 收录(需添加 noindex),更不用提运营人员管理网页的原则是怎么方便怎么来了;考虑到这些页面的 SEO 信息基本不会改动,直接代码层面写死即可。
现在我们已经实现了博客文章标题的搜索,不过目前我们只能获取到第一页的结果,我们还要在这个组件内实现翻页功能。
5.3 Strapi + Next.JS + JSX:文章列表分页
我们先把分页逻辑单独剥离出来,创建一个工具到 @/utils 目录下:
代码已复制!
export function generatePagination(currentPage, pageCount) { return [ '< Prev', currentPage > 3 && 1, currentPage > 4 && '...', currentPage > 2 && currentPage - 2, currentPage > 1 && currentPage - 1, currentPage, currentPage < pageCount && currentPage + 1, currentPage < pageCount - 1 && currentPage + 2, currentPage + 3 < pageCount && '...', currentPage + 2 < pageCount && pageCount, 'Next >' ].filter(Boolean) }
这里分页数据的逻辑有点复杂,逐条解释一下:
currentPage是传入的当前页码,pageCount则是最大页码;- 定义一个数组
pagination,用于装载分页按钮的内容以做循环渲染:- 数组内的
< Prev、Next >永远显示:- 如果当前页码 = 1 则 Prev 不可用,当前页码 = 最大页码则 Next 不可用;
- 当前页码的 ±2 的页码会添加到数组;
- 当前页码 > 3 时向数组添加 1 ,当前页码 < 最大页码 - 2 时则添加最大页码;
- 当前页码 > 4 时,页码左侧的
...会被添加到数组,而当当前页码 < 最大页码 - 3 时则添加页码右侧的...: - 不存在的内容为
false,不应添加到数组,要用array.filter(Boolean)方法过滤掉;
- 举例来说,最大页码为 7 时,对应的页码数组为:
- 如在第 2 页:
Prev, 1, 2, 3, 4, ..., 7, Next - 如在第 4 页:
Prev, 1, 2, 3, 4, 5, 6, 7, Next - 如在第 5 页:
Prev, 1, ..., 3, 4, 5, 6, 7, Next - 如在第 7 页:
Prev, 1, ..., 5, 6, 7, Next
- 如在第 2 页:
- 数组内的
了解完工具逻辑,对应修改 ArticleCardContainer.js 的内容即可实现功能:
代码已复制!
// Import Next components import Link from 'next/link' // Import components import ArticleCard from '@/components/shared/ArticleCard' // Import utilities import { generatePagination } from '@/utils/pagination' export default function ArticleCardContainer ({ globalData, data, position }){ // 封装渲染逻辑 const renderCategory = (category, categoryIndex) => { // 根据页面类型执行数组判断 const articles = position === "blog" ? category.articles : category.data // 设定位于 Blog 与 Category 下的 tag.name 与 tag.slug,因为它们都是相同的,不需要在 Article 里头拿数据 const tag = position !== "blog-search" ? { name: category.name, slug: category.slug } : null // 添加分页功能,处理非博客页面的数据 //// 定义当前页码与最大页码 const currentPage = position !== "blog" && category.meta.pagination.page const pageCount = position !== "blog" && category.meta.pagination.pageCount //// 调用工具函数 const pagination = generatePagination(currentPage, pageCount) //// 将 onPageChange 方法单独提取出来 const onPageChange = position !== "blog" && category.onPageChange //// 定义 onClick 事件逻辑 const handlePaginationClick = (page) => { if (!onPageChange) return if (typeof page === 'number') { onPageChange(page) } else if (page === '< Prev') { onPageChange(currentPage - 1) } else if (page === 'Next >') { onPageChange(currentPage + 1) } } // 检测数据数组是否有长度,有的话渲染内容;如数组无长度且当前分页超出最大页码,则直接渲染错误提示 return articles.length ? ( <div key={categoryIndex} className="space-y-6"> <div className="px-6 mx-6 max-lg:space-y-3 md:px-9 lg:flex lg:justify-between lg:items-center lg:px-0"> <h3 className="text-2xl capitalize">{category.name}</h3> {/* 判断是否位于博客文章搜索页或文章目录页,是就显示文章基数,否则一律为 View all 链接,点击跳转至目录 */} {(position === "blog-search" || position === "category") ? <p>{category.meta.pagination.total} Articles</p> : <Link href={`/category/${category.slug}`}>View all >></Link>} </div> <div className="max-lg:overflow-x-scroll hide-scrollbar"> <div className={`gap-6 px-6 md:grid md:grid-cols-2 md:px-9 lg:px-0 xl:grid-cols-3 ${position === 'latest' && 'max-lg:w-240'}`}> {articles && articles.map((articleCard, articleCardIndex) => { {/* 如果在搜索页面则从数组循环中为各个文章定义 searchArticlesTag ,否则使用页面内的 Tag */} const searchArticlesTag = position === "blog-search" && { name: articleCard.category.name, slug: articleCard.category.slug } return (<ArticleCard key={articleCardIndex} globalData={globalData} data={articleCard} tag={searchArticlesTag ? searchArticlesTag : tag}/> ) })} </div> </div> {/* 为页面添加分页按钮 */} { position !== "blog" && <div className="flex justify-center mt-12 space-x-4 overflow-x-scroll"> {pagination && pagination.map((page, paginationIndex) => ( <button key={paginationIndex} className={`shrink-0 min-w-9 h-12 p-2 rounded-lg not-disabled:border border-secondary transition-colors duration-300 not-disabled:cursor-pointer not-disabled:hover:bg-secondary not-disabled:hover:text-white disabled:text-secondary/67 disabled:cursor-not-allowed ${page === currentPage ? 'bg-secondary text-white' : (page === '< Prev' || page === 'Next >') ? 'max-md:hidden' : ''}`} onClick={() => handlePaginationClick(page)} disabled={page === '...' || (page === '< Prev' && currentPage === 1) || (page === 'Next >' && currentPage === pageCount)}>{page}</button> ))} </div> } </div> ) : (pageCount < currentPage) && <p>Invalid page count, please input correct value.</p> } // 执行渲染 return ( <div className="mt-30 space-y-30 lg:max-w-8xl lg:px-18 lg:mx-auto"> {/* 判断页面位置,除了博客页面有多个 category 需要渲染,其他页面只取单条 data */} {position === "blog" ? data && data.map((category, categoryIndex) => renderCategory(category, categoryIndex)) : renderCategory(data, 0) } </div> ) }
这里解释一下渲染逻辑:
- 循环渲染数组为按钮时,判断内容以添加原子类名以及
disabled参数,并设定onClick事件中的内容:- 渲染以下数据时添加
disabled参数:- 当前停留在第 1 页时的
< Prev; - 当前停留于最大页码时的
Next >; - 所有的
...;
- 当前停留在第 1 页时的
- 为非禁用状态的按钮添加悬停动画
not-disabled:*,并为两个状态添加指针样式; - 当前渲染内容等于当前页码:className 高亮(
bg-secondary text-white),且为< Prev与Next >按钮添加max-md:hidden,确保移动端交互舒适; - 当前渲染内容为
< Prev或Next >时,onClick事件为设定页码为当前页面 - 1 与 + 1。
- 渲染以下数据时添加
最后让我们完成 Category 动态页面:
代码已复制!
// Import React hooks import { useRouter } from 'next/router' // Import libraries import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' import ArticleCardContainer from '@/components/shared/ArticleCardContainer' // Set-up per page variants const pageSize = 12 // 设定分页参数为 12 ,即每页最多 12 个 export default function Category ({ globalData, data, slug }){ const router = useRouter() // 添加分页处理函数 const handlePageChange = (newPage) => { router.push(`/category/${slug}?page=${newPage}`) } const categoryData = { data: data.data[0].articles, meta: data.meta, name: slug, slug: slug, onPageChange: handlePageChange } const pagination = categoryData.meta.pagination // 处理分类信息,传入 Information 组件 const categoryInformation = { "heading": `Articles of "${slug}"`, "description": `Found ${pagination.total} articles in ${pagination.pageCount} pages` } return ( <> <CustomizedHead globalData={globalData} data={data.data[0].seo} /> <Information globalData={globalData} data={categoryInformation} position="category" /> <ArticleCardContainer globalData={globalData} data={categoryData} position="category"/> </> ) } export async function getServerSideProps({ params, query }) { const slug = params.slug const page = parseInt(query.page || '1', 10) // 验证页码 if (!Number.isInteger(page) || page < 1) { return { redirect: { destination: `/category/${slug}?page=1`, permanent: false, }, } } const response = await fetcher( `categories?filters[slug][$eq]=${slug}&populate[0]=articles&populate[1]=articles.author&populate[2]=articles.category&populate[3]=articles.cover&populate[4]=seo&pagination[page]=${page}&pagination[pageSize]=${pageSize}` ) // 检查页码是否超出范围 if (page > response.meta.pagination.pageCount) { return { redirect: { destination: `/category/${slug}?page=1`, permanent: false, }, } } return { props: { data: response, slug } } }
这样所有页面的搜索和分页逻辑就都做好了。
5.4 Next.JS:为 SSG 页面设置 ISR 以实现性能优化与预生成
介于我们的项目是仅需轻量托管的小型网站,在生产环境下我们需要为部分 SSG 页面添加 ISR 以提升性能,以下是 ISR 配置建议:
| 页面 | 渲染方式 | 需要 ISR | 建议 revalidate | 理由 |
|---|---|---|---|---|
| index.js | SSG | ✅ | 3600秒(1小时) | 展示最新内容,需要定期更新 |
| about.js | SSG | ✅ | 86400秒(24小时) | 内容很少变动 |
| blog.js | SSG | ✅ | 1800秒(30分钟) | 展示最新内容,更新频繁 |
| course.js | SSG | ✅ | 3600秒(1小时) | 课程更新频率中等 |
| _app.js | N/A | - | ❌ | 全局配置,无需 ISR |
| _document.js | N/A | ❌ | - | HTML 配置,无需 ISR |
| _error.js | SSR | ❌ | - | 错误页面不应缓存 |
| 404.js | 静态 | ❌ | - | 静态错误页面 |
| 500.js | 静态 | ❌ | - | 静态错误页面 |
| blog/[...slug].js | SSG | ✅ | 1800秒(30分钟) | 文章内容相对稳定,定期更新即可 |
| blog/search.js | SSR | ❌ | - | 搜索结果依赖 URL 参数,需实时数据 |
| category/[slug].js | SSR | ❌ | - | 分类分页依赖 URL 参数,需实时数据 |
现在让我们为这五个页面的 SSG 函数逐一添加 ISR 参数:
代码已复制!
... export async function getStaticProps() { const response = await fetcher(`home?populate[0]=introduction&populate[1]=frameworks&populate[2]=comparisons&populate[3]=comparisonInformations.contents&populate[4]=comparisonInformations.contents.detailList&populate[5]=comparisonInformations.contents.logo&populate[6]=guideChapters&populate[7]=guideChapterContents&populate[8]=guideChapters.image&populate[9]=playground&populate[10]=playgroundContents&populate[11]=seo`) return { props: { data: response.data }, revalidate: 3600 // 每小时重新生成一次 } }
代码已复制!
... export async function getStaticProps() { const response = await fetcher(`course?populate[0]=banner&populate[1]=banner.image&populate[2]=courses.image&populate[3]=specializedCourses&populate[4]=specializedCoursesContents.image&populate[5]=seo`) return { props: { data: response.data }, revalidate: 3600 // 每小时重新生成一次 } }
代码已复制!
... export async function getStaticProps() { const response = await fetcher(`blog?populate[0]=articlesOfTheDay.author&populate[1]=articlesOfTheDay.category&populate[2]=articlesOfTheDay.cover&populate[3]=articlesOfTheDay2.author&populate[4]=articlesOfTheDay2.category&populate[5]=articlesOfTheDay2.cover&populate[6]=banner.image&populate[7]=featuredCategories.articles.cover&populate[8]=featuredCategories.articles.author&populate[9]=seo`) const latestArticles = await fetcher(`articles?sort=publishedAt:desc&populate=*&pagination[limit]=3`) return { props: { data: response.data, latestArticles: latestArticles.data }, revalidate: 1800 // 每 30 分钟重新生成一次 } }
代码已复制!
... export async function getStaticProps() { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/about?populate[0]=banner.image&populate[1]=seo`) const response = await res.json() return { props: { data: response.data }, revalidate: 86400 // 每天重新生成一次 } }
代码已复制!
... export async function getStaticProps({ params }) { const slug = params.slug.join('/') const article = await fetcher(`articles?filters[slug][$eq]=${slug}&populate[0]=cover&populate[1]=author.avatar&populate[2]=category&populate[3]=content.image&populate[4]=seo`) return { props: { data: article.data[0] }, revalidate: 1800 // 每 30 分钟重新生成一次 } }
接下来介绍一下 ISR 工作原理:ISR(Incremental Static Regeneration,增量静态再生成)是 Next.js 提供的一种混合渲染策略,它结合了 SSG 和 SSR 的优点:
工作流程:
- 首次构建:在执行
next build时,Next.js 会为所有配置了getStaticProps的页面生成静态 HTML 文件; - 首次访问:用户访问页面时,直接返回预生成的静态 HTML,速度极快(类似 SSG);
- 后台重新生成:
- 当
revalidate时间到期后,下一个访问该页面的用户仍然会看到旧的缓存版本(保证速度); - 同时,Next.js 会在后台触发页面重新生成;
- 重新生成完成后,新的静态页面会替换旧版本;
- 当
- 后续访问:新用户将看到更新后的内容。
关键特性:
- SWR (Stale-While-Revalidate 策略):即使内容过期,用户仍然能看到旧内容,不会出现加载延迟;
- 自动更新:无需手动重新部署,内容会自动更新;
- 降低服务器负载:大部分请求都返回静态缓存,只有少数请求触发重新生成;
- 容错机制:如果重新生成失败,旧的静态页面仍然可用。
示例时间线:
- 假设
revalidate: 3600(1小时):- 00:00 - 构建时生成静态页面(版本 A)
- 00:30 - 用户访问,返回版本 A(缓存命中)
- 01:05 - 用户访问,返回版本 A(缓存已过期,但仍返回旧版本)
- 后台开始重新生成(版本 B)
- 01:06 - 版本 B 生成完成
- 01:10 - 用户访问,返回版本 B(新版本)
与其他渲染方式的对比:
| 特性 | SSG | ISR | SSR |
|---|---|---|---|
| 首次加载速度 | ⚡ 极快 | ⚡ 极快 | 🐢 较慢 |
| 内容新鲜度 | ❌ 需重新部署 | ✅ 自动更新 | ✅ 实时 |
| 服务器负载 | ✅ 极低 | ✅ 低 | ❌ 高 |
| 适用场景 | 完全静态内容 | 需定期更新的内容 | 实时动态内容 |
选择合适的 revalidate 时间:
- 内容更新频繁:1800秒(30分钟)
- 内容更新中等:3600秒(1小时)
- 内容很少更新:86400秒(24小时)
注意事项:
- ⚠️
revalidate是最小重新验证时间,不是精确时间 - ⚠️ 只有在有新请求时才会触发重新生成
- ⚠️ 重新生成失败时会继续使用旧缓存
- ⚠️ 动态路由需要配合
getStaticPaths使用
这样一来,我们在构建项目时就能同时兼顾客户端与服务端的性能优化了,用户体验也将大大提升。
5.5 Strapi Lifecycles hooks:客户联络自动转发邮件
本小节我们要让 Strapi 服务于内容更新时自动转发邮件,比如 Contact 页面的提交。步骤如下:
- 添加第三方邮件服务依赖
- 配置
config/plugin.js - 在
.env中添加邮箱信息 - 为相关页面添加
lifecycles.js文件并编辑内容
首先我们需要停止 Strapi 服务并安装依赖:
代码已复制!
# 于停止 Strapi 服务后 yarn add @strapi/provider-email-nodemailer
然后编辑 Strapi 目录下的插件配置文件:
代码已复制!
module.exports = ({ env }) => ({ email: { config: { provider: 'nodemailer', providerOptions: { host: env('EMAIL_SMTP_HOST', 'smtp.example.com'), port: env('EMAIL_SMTP_PORT', 587), auth: { user: env('EMAIL_SMTP_USERNAME'), pass: env('EMAIL_SMTP_PASSWORD'), }, // 可选配置 secure: false, // 如果使用 465 端口,设置为 true // tls: { // rejectUnauthorized: false // 开发环境可以设置为 false // } }, settings: { defaultFrom: env('EMAIL_ADDRESS_FROM', 'noreply@example.com'), defaultReplyTo: env('EMAIL_ADDRESS_REPLY', 'noreply@example.com'), }, }, }, });
最后在 Strapi 目录下的 .env 中添加配置:
代码已复制!
# Email EMAIL_SMTP_HOST=smtp.mail.com EMAIL_SMTP_PORT=587 EMAIL_SMTP_USERNAME=your-email@mail.com EMAIL_SMTP_PASSWORD=your-app-password EMAIL_ADDRESS_FROM=noreply@yourdomain.com EMAIL_ADDRESS_REPLY=support@yourdomain.com
然后去找到你的邮件服务商,比如 Foxmail / QQ 邮箱就是 mail.qq.com ,阿里邮箱的则为 qiye.aliyun.com ,这里注册登陆不多赘述,在各大服务商按照教程获取你的 SMTP 密钥,替换到下述内容里头:
代码已复制!
# Email ## 个人测试用 QQ 邮箱就行,下述内容以 QQ 邮箱作为参考 EMAIL_SMTP_HOST=smtp.qq.com ## SMTP 端口号一般要改成465 EMAIL_SMTP_PORT=465 ## 填写你自己的邮箱地址与密钥 EMAIL_SMTP_USERNAME=[你的邮箱地址] EMAIL_SMTP_PASSWORD=[你的 SMTP 密钥,不是你在服务商那边的登陆密码,要去 SMTP 设置里自行获取] ## 填写发送邮箱地址 ### 一般格式是 noreply@你的域名.com EMAIL_ADDRESS_FROM=[发送邮箱地址] ## 填写接收邮箱地址 ### 一般格式是 support@你的域名.com EMAIL_ADDRESS_REPLY=[接收邮箱地址]
对应修改一下 plugins.js :
代码已复制!
... port: env('EMAIL_SMTP_PORT', 465), // 修改端口号为465 auth: { user: env('EMAIL_SMTP_USERNAME'), pass: env('EMAIL_SMTP_PASSWORD'), }, // 可选配置 secure: true, // 如果使用 465 端口,设置为 true
以上内容确认无误并保存后,重启 Strapi 项目:
代码已复制!
yarn dev
先在请打开你的 Strapi 设置,找到 Email Plugin - Configuration 页面,填写一个接收邮件执行收发测试:

成功收发后,我们来一起实现自动转发邮件。现在请于 Strapi 项目定位到 src/api/contact/content-types/contact 目录下,创建一个 lifecycles.js 文件并填写下述内容:
代码已复制!
module.exports = { // 使用 afterCreate hooks ,以在创建完毕后执行 async afterCreate(event) { // 将数据映射到 result 对象 const { result } = event; // 在 Strapi 控制台打印:内容已创建 console.log('Content created!') try{ // 使用 Strapi Email 插件执行 services.email.send() 方法 await strapi.plugins['email'].services.email.send({ to: 'demo@example.com', from: 'demo@example.com', subject: 'You have one new message from rainydesign.cn', html: ` <h4>User message:</h4> <table> <tbody> <tr> <td><p style="margin-right: 12px"><strong>Name:</strong><p></td> <td><p>${result.name}<p></td> </tr> <tr> <td><p style="margin-right: 12px"><strong>Email or Whatsapp:</strong><p></td> <td><p>${result.tel}<p></td> </tr> <tr> <td><p style="margin-right: 12px"><strong>Requirements:</strong><p></td> <td><p>${result.wechatOrQQ}<p></td> </tr> <tr> <td><p style="margin-right: 12px"><strong>Message:</strong><p></td> <td><p>${result.message}<p></td> </tr> </tbody> </table> `, }) // 在 Strapi 控制台打印:邮件已发送 console.log('E-mail sended!') } catch(err) { // 在 Strapi 控制台打印错误 console.log(err); } } }
将其中的 to 和 from 值改成你自己的邮箱即可。这里的 HTML 是一个参考结构,你可根据自己的喜好进行调整;做完这一切之后,请在 Next.JS 网站项目中提交 Contact 测试:

提交后在 Strapi 后台能看到 Contact 文档已创建,且邮箱收发功能正常就大功告成了。
5.6 Next.JS:自定义错误页面如 404、500 等
现在让我们调整一下 404.js 、 500.js 与 _error.js 的代码以完成错误页的自定义:
代码已复制!
// Import Next.JS components import Head from 'next/head' import Image from 'next/image' import Link from 'next/link' export default function Custom404({ globalData }){ const metaTitle = "404 - Page not found" + globalData.globalAddOn; return ( <> <Head> <title>{metaTitle}</title> <meta name="description" content="Page not found" /> </Head> <div className="flex flex-col justify-center items-center w-full h-[50vh] pt-32 lg:pt-48"> <Image src="/logo/logo-128x128-with-padding.svg" width={128} height={128} alt={`Logo${globalData.globalAddOn}`} loading="eager" /> <h1>404 - Page not found</h1> <Link href="/" className="px-4 py-2 mt-3 rounded-full border border-primary transition-colors duration-300 hover:bg-primary hover:text-white">Back to home</Link> </div> </> ) }
代码已复制!
// Import Next.JS components import Head from 'next/head' import Image from 'next/image' import Link from 'next/link' export default function Custom500({ globalData }){ const metaTitle = "500 - Internal server error" + globalData.globalAddOn; return ( <> <Head> <title>{metaTitle}</title> <meta name="description" content="Internal server error" /> </Head> <div className="flex flex-col justify-center items-center w-full h-[50vh] pt-32 lg:pt-48"> <Image src="/logo/logo-128x128-with-padding.svg" width={128} height={128} alt={`Logo${globalData.globalAddOn}`} loading="eager" /> <h1>500 - Internal server error</h1> <Link href="/" className="px-4 py-2 mt-3 rounded-full border border-primary transition-colors duration-300 hover:bg-primary hover:text-white">Back to home</Link> </div> </> ) }
代码已复制!
// Import Next.JS components import Image from 'next/image' import Link from 'next/link' // Import components import CustomizedHead from '@/components/global/CustomizedHead' function Error({ globalData, statusCode }) { // 这里拿到状态编码 const data = { metaTitle: statusCode ? `${statusCode} - Server Error` : 'Client Error' + globalData.globalAddOn, metaDescription: statusCode ? `A ${statusCode} error occurred on server` : 'An error occurred on client' } return ( <> <CustomizedHead globalData={globalData} data={data}/> <div className="flex flex-col justify-center items-center w-full h-[50vh] pt-32 lg:pt-48"> <Image src="/logo/logo-128x128-with-padding.svg" width={128} height={128} alt={`Logo${globalData.globalAddOn}`} loading="eager" /> <h1>{statusCode ? `${statusCode} - Server Error` : 'Client Error'}</h1> <Link href="/" className="px-4 py-2 mt-3 rounded-full border border-primary transition-colors duration-300 hover:bg-primary hover:text-white">Back to home</Link> </div> </> ) } // 通过 getInitialProps 获取错误参数 Error.getInitialProps = ({ res, err }) => { // 定义一个 statusCode 变量并 return 出去,如果有响应对象就拿到状态编码,如无则获取错误对象的编码,两者皆无则默认为 404 const statusCode = res ? res.statusCode : err ? err.statusCode : 404 return { statusCode } } export default Error
这样一来所有的错误页就准备就绪了。
globalAddOn 并不是经常变化的内容,且错误页面无需做特定 SEO ,所以引用的 globalData 数据仅在 build 阶段有效,这对整个项目而言无伤大雅。5.7 Next.JS:创建 sitemap.xml 文件并自更新
最后,我们还需要为全站添加站点地图,且自动更新内容。
首先,我们需要在 Next.JS 项目下的 @/pages/ 目录下创建一个 sitemap.xml.js 文件:
代码已复制!
// Import libraries import { fetcher } from "@/lib/api"; function generateSiteMap(articles, categories) { return `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <!-- Manually add sitemaps --> <url> <loc>${process.env.NEXT_PUBLIC_SITE_URL}/</loc> <changefreq>daily</changefreq> <priority>1.0</priority> </url> <url> <loc>${process.env.NEXT_PUBLIC_SITE_URL}/blog</loc> <changefreq>daily</changefreq> <priority>0.8</priority> </url> <url> <loc>${process.env.NEXT_PUBLIC_SITE_URL}/course</loc> <changefreq>weekly</changefreq> <priority>0.7</priority> </url> <url> <loc>${process.env.NEXT_PUBLIC_SITE_URL}/about</loc> <changefreq>monthly</changefreq> <priority>0.6</priority> </url> <!-- Automatic generate sitemaps --> ${articles?.data?.map((item) => { return ` <url> <loc>${process.env.NEXT_PUBLIC_SITE_URL}/blog/${item.slug}</loc> <lastmod>${item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString()}</lastmod> <changefreq>weekly</changefreq> <priority>0.7</priority> </url> `;}).join('') || ''} ${categories?.data?.map((item) => { return ` <url> <loc>${process.env.NEXT_PUBLIC_SITE_URL}/category/${item.slug}</loc> <lastmod>${item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString()}</lastmod> <changefreq>weekly</changefreq> <priority>0.6</priority> </url> `;}).join('') || ''} </urlset> `; } function SiteMap() { // getServerSideProps will do the heavy lifting } export async function getServerSideProps({ res }) { try { // Get data const articles = await fetcher('articles'); const categories = await fetcher('categories'); // Generate XML sitemap with data const sitemap = generateSiteMap(articles, categories); // Added headers res.setHeader('Content-Type', 'text/xml'); res.setHeader('Cache-Control', 'public, s-maxage=86400, stale-while-revalidate'); // Write sitemap XML file res.write(sitemap); res.end(); return { props: {}, }; } catch (error) { console.error('Error generating sitemap:', error); // Return basic file const basicSitemap = generateSiteMap({ data: [] }, { data: [] }); res.setHeader('Content-Type', 'text/xml'); res.write(basicSitemap); res.end(); return { props: {}, }; } } export default SiteMap;
为了让搜索引擎发现我们的 sitemap,我们还需要创建 robots.txt 文件并添加如下内容:
代码已复制!
export default function Robots() { // getServerSideProps will handle the response } export async function getServerSideProps({ res }) { const robotsTxt = `User-agent: * Allow: / Sitemap: ${process.env.NEXT_PUBLIC_SITE_URL}/sitemap.xml `; res.setHeader('Content-Type', 'text/plain'); res.setHeader('Cache-Control', 'public, s-maxage=86400, stale-while-revalidate'); res.write(robotsTxt); res.end(); return { props: {}, }; }
到了这里,整个网站项目就搭建完毕了。
稍作休息,最后的最后让我们把项目部署好。
下一节课程:
网站部署:Cloudflare、Vercel或租赁云服务器(仍在撰写,敬请期待!)

