成为全栈·Next.js 网站前台篇·站点设置如何驱动页头、页脚与 SEO
站点名称、Logo、描述、关键词和版权不是散落在组件里的常量。它们是一份公开配置,需要同时驱动页面外壳和 metadata,并在接口失败或字段为空时有一致降级。

前言
管理后台已经允许修改站点设置,如果前台页头、页脚和 SEO 仍各写一份默认文案,运营人员改一次名称就会得到三个版本。更隐蔽的问题是:Logo URL 来自配置,若不限制协议,公共页头可能渲染危险或无效地址。
当前项目用 siteChrome() 作为页面外壳的公开数据入口,让 Header、Footer 和根 metadata 读取同一份设置,并把失败降级集中起来。
配置字段各有消费位置
| 字段 | 页面消费 | SEO 消费 | 空值策略 |
|---|---|---|---|
siteName |
页头品牌、页脚默认版权 | 标题模板 | 使用项目默认名称 |
siteTitle |
--- | 默认页面标题 | 回退 siteName |
siteDescription |
首页介绍 | description | 使用默认描述 |
siteKeywords |
--- | keywords | 空时省略 |
logoUrl |
页头图标 | 可扩展分享图 | 无效时使用字母标记 |
copyright |
页脚 | --- | 年份 + siteName |

请求内复用与失败降级集中处理
ts
export const settings = cache(getSiteSettings)
export const categories = cache(getCategoryTree)
export const fallbackSite = {
siteName: '成为全栈开发工程师',
siteTitle: '',
siteKeywords: '',
siteDescription: '用一个真实系统,串起全栈开发的每一步。',
}
export const siteChrome = cache(async () => ({
site: await settings().catch(() => fallbackSite),
categories: await categories().catch(() => []),
}))
页头属于可选外壳,设置接口暂时失败时可以使用默认身份;文章正文则不能用空对象降级。错误策略取决于模块是否为核心任务,而不是"所有 GET 都 catch"。
React cache() 避免同一次渲染中 Header、Footer 与 metadata 重复计算。底层公开 fetch 仍负责跨请求 60 秒再验证,两层职责不同。
根 metadata 使用同一份设置
tsx
export async function generateMetadata(): Promise<Metadata> {
const { site } = await siteChrome()
return {
metadataBase: new URL(
process.env.NEXT_PUBLIC_SITE_URL || 'http://127.0.0.1:13001',
),
title: {
default: site.siteTitle || site.siteName || '成为全栈开发工程师',
template: `%s · ${site.siteName}`,
},
keywords: site.siteKeywords || undefined,
description: site.siteDescription || fallbackSite.siteDescription,
}
}
页面级 metadata 只覆盖文章标题和摘要,根 layout 继续提供标题模板与 metadataBase。正式构建必须配置生产域名,否则 canonical 和分享地址可能指向本机。
Logo 地址先经过协议白名单
ts
export function safeLink(value?: string | null): string | undefined {
if (!value || /[\\\r\n]/.test(value)) return undefined
if (value.startsWith('/') && !value.startsWith('//')) return value
try {
const url = new URL(value)
return ['https:', 'http:'].includes(url.protocol) ? url.href : undefined
} catch {
return undefined
}
}
tsx
const logo = 'logoUrl' in site ? safeLink(site.logoUrl) : undefined
{logo ? (
<Picture src={logo} alt="" />
) : (
<span className="mark">F</span>
)}
Logo 旁边已有站点名称,因此图片 alt 为空,避免读屏重复品牌。无效地址回退字母标记,不渲染破损图片。
Footer 使用真实配置,不制造假版权
tsx
<footer>
<nav aria-label="页脚导航">
<Link href="/about">关于网站</Link>
<Link href="/articles">全部文章</Link>
<Link href="/categories">分类导航</Link>
<Link href="/tags">标签索引</Link>
</nav>
<div>
{site.copyright
? site.copyright
: `© ${new Date().getFullYear()} ${site.siteName}`}
</div>
</footer>
配置字段为空时才回退,不用 || 随意覆盖合法的短文案。版权属于展示配置,不应在代码里硬编码某个年份长期不变。
60 秒更新窗口是一项业务承诺
ts
return fetch(url, {
cache: 'force-cache',
next: { revalidate: 60, tags: ['site-settings'] },
})
后台保存设置后,前台可能在再验证窗口内继续显示旧值,第一个过期请求还可能先拿旧值并触发后台更新。这个行为需要在后台提示或运营流程里被接受。
若 Logo 或站点名称要求立即切换,应增加受保护的按需失效通道,而不是把所有公开请求改为 no-store。
配置变更要跨页面核对
text
1. 将 siteName 从 A 改为 B
2. 在 TTL 内检查旧值仍可能存在
3. 过期并完成再验证后检查 Header、Footer、首页 title
4. 打开文章详情,检查标题模板变为"文章标题 · B"
5. 将 logoUrl 改为非法协议,确认回退标记
6. 清空 copyright,确认年份与站名回退
7. 停止设置接口,确认正文仍可读且外壳使用默认配置

| 故障 | 合理表现 |
|---|---|
| 设置接口 500 | 外壳用默认值,核心内容继续 |
| 分类接口 500 | 导航退化为基本入口 |
| Logo 404 | 图片组件失败策略或标记回退需检查 |
| 配置为空字符串 | 按字段定义省略或回退 |
| 正式域名缺失 | 构建虽可运行,但不得当作上线产物 |
适用边界
当前设置是全站单租户配置。如果未来支持多域名、多语言或多品牌,缓存键必须包含租户与 locale,不能继续使用一个全局 siteChrome() 结果。
频繁运营位也不适合无限加入站点设置。首页焦点、活动 Banner 应有生效时间与独立模型,避免一个配置对象变成无边界 CMS。
小结
站点设置的价值不是减少几处常量,而是建立单一公开身份:Header、Footer 与 SEO 读取同一事实,空值和失败使用同一降级规则,更新窗口也有明确预期。
配置驱动只有在消费位置、缓存时序和安全过滤一起成立时,才真正比硬编码可靠。
延伸阅读
- Next.js SEO
- 会员公开主页与通知中心
- 响应式、可访问性与错误状态
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
