Epic Stack SEO 实战:基于 Resource Route 的 robots.txt 与 sitemap.xml 定制指南
【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack
搜索引擎抓取是许多全栈应用上线后的必修课:站点地图(sitemap.xml)帮助爬虫发现内容,robots.txt 声明抓取策略。Epic Stack 作为开箱即用的全栈 Starter,把这两项能力直接内建在框架路由层,开发者无需编写任何服务器中间件,就能在项目里以"声明式"的方式完成 SEO 基础建设。本文将以 docs/seo.md 为主线,结合仓库源码,讲解 Epic Stack 中 meta 标签、robots.txt、sitemap.xml 的开箱用法与按路由定制方案,读完你就能在自己的 Epic Stack 项目中配置一套完整的搜索引擎可见性策略。
一、Epic Stack 的 SEO 能力全景
Epic Stack 的 SEO 能力由两层构成:
- 按路由设置 meta 标签:React Router(Remix)框架原生支持在每条路由上通过
meta导出声明页面的<title>、<meta name="description">等标签,无需手动拼装<head>。 - 资源路由(Resource Route)生成 robots.txt 与 sitemap.xml:借助
@nasa-gcn/remix-seo库,以/robots.txt和/sitemap.xml两个端点动态输出标准文件,所有逻辑都是框架内的 TypeScript 代码。
其中第 2 层是本仓库的默认配置重点:sitemap 默认包含所有路由,但你可以通过路由的handle导出按需定制每条路由是否进入、以及如何进入站点地图。下面先从默认实现讲起。
二、开箱即用的 robots.txt 与 sitemap.xml
在 Epic Stack 中,这两个端点分别由_seo布局下的两个资源路由提供:
- app/routes/_seo/robots[.]txt.ts
- app/routes/_seo/sitemap[.]xml.ts
2.1 robots.txt:声明 sitemap 位置
robots 路由 的实现非常简洁,核心只有一行:
import { generateRobotsTxt } from '@nasa-gcn/remix-seo' import { getDomainUrl } from '#app/utils/misc.tsx' import { type Route } from './+types/robots[.]txt.ts' export function loader({ request }: Route.LoaderArgs) { return generateRobotsTxt([ { type: 'sitemap', value: `${getDomainUrl(request)}/sitemap.xml` }, ]) }generateRobotsTxt接收一个规则数组,这里仅声明了一条Sitemap:指令,指向当前域名的/sitemap.xml。注意:
- 它没有添加
User-agent/Disallow规则,即对所有爬虫默认放行,仅告知站点地图位置; - 域名由
getDomainUrl(request)动态推导(见 2.3),因此无需硬编码生产域名。
2.2 sitemap.xml:动态生成并做边缘缓存
sitemap 路由 调用generateSitemap完成站点地图的生成:
import { generateSitemap } from '@nasa-gcn/remix-seo' import { getDomainUrl } from '#app/utils/misc.tsx' import { type Route } from './+types/sitemap[.]xml.ts' export async function loader({ request, context }: Route.LoaderArgs) { // TODO: This is typeerror is coming up since of the remix-run/server-runtime package. We might need to remove/update that one. // @ts-expect-error return generateSitemap(request, context.serverBuild.routes, { siteUrl: getDomainUrl(request), headers: { 'Cache-Control': `public, max-age=${60 * 5}`, }, }) }几个值得注意的实现细节:
- 自动枚举路由:
context.serverBuild.routes是构建时生成的路由表,generateSitemap会遍历其中所有路由,默认把每条路由都作为独立 URL 输出(URL 依据框架对路由文件名的约定自动推导)。 - CDN 友好的缓存:响应头显式设置了
Cache-Control: public, max-age=300(5 分钟),方便接入 CDN 或代理层缓存,降低动态生成的频率。仓库中源码注释也说明此处存在一个来自remix-run/server-runtime的类型兼容问题,当前以@ts-expect-error规避。 - 动态 URL:通过
siteUrl参数(来自getDomainUrl)拼出完整的绝对 URL,而非相对路径。
2.3 getDomainUrl:域名推导的底层原理
sitemap.xml与robots.txt都依赖 app/utils/misc.tsx 中的getDomainUrl:
export function getDomainUrl(request: Request) { const host = request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') ?? new URL(request.url).host const protocol = request.headers.get('X-Forwarded-Proto') ?? 'http' return `${protocol}://${host}` }推导优先级依次为:X-Forwarded-Host→host→ 请求 URL 中的 host;协议同样优先取X-Forwarded-Proto,默认回退为http。这套逻辑对部署在反代/负载均衡(如 Fly.io、Nginx)之后的应用尤为重要——只有在生产环境正确透传X-Forwarded-*头,站点地图中的 URL 才会指向正确的公开域名,否则可能出现 "http://内部地址/sitemap.xml" 之类的问题。
三、默认策略:所有路由进入 sitemap 的安全边界
默认情况下,所有路由都会被generateSitemap收录。这对"页面即内容"的营销站点很友好,但意味着像登录、注册、设置中心这类私有页面也会被列进站点地图。因此在 Epic Stack 中有一条明确的工程约定(见 docs/seo.md):
只有面向公众的页面才应被包含进 sitemap.xml。
从源码结构看,仓库已经为大量非公开路由显式关闭了收录,例如:
- 认证类:app/routes/_auth/login.tsx、app/routes/_auth/signup.tsx、app/routes/_auth/verify.tsx、app/routes/_auth/forgot-password.tsx、app/routes/_auth/reset-password.tsx
- 设置中心:
app/routes/settings/profile/下的index.tsx、change-email.tsx、password.tsx、photo.tsx、connections.tsx、two-factor/*等全部路由 - 管理后台:app/routes/admin/cache/index.tsx
它们的写法完全一致——通过路由handle导出返回null的getSitemapEntries(细节见下一节)。这为自定义项目提供了一个可直接照搬的"排除清单"模式。
四、按路由定制 sitemap:SEOHandle 与 handle 导出
@nasa-gcn/remix-seo通过SEOHandle类型约定路由的 sitemap 行为。你需要在目标路由文件中:
- 从
@nasa-gcn/remix-seo导入type SEOHandle; - 导出
handle对象,实现getSitemapEntries。
4.1 动态生成多个条目:从数据库拉取内容
对于内容型站点(博客、文档、商品列表),最常见的需求是让每篇内容都生成独立的站点地图条目。官方推荐写法(与 docs/seo.md 一致)如下:
// routes/blog/_layout.tsx import { type SEOHandle } from '@nasa-gcn/remix-seo' import { serverOnly$ } from 'vite-env-only/macros' export const handle: SEOHandle = { getSitemapEntries: serverOnly$(async (request) => { const blogs = await db.blog.findMany() return blogs.map((blog) => { return { route: `/blog/${blog.slug}`, priority: 0.7 } }) }), }要点拆解:
- 返回条目结构:数组中的每个元素包含
route(相对路径)和可选的priority(优先级,0.0~1.0,用于提示搜索引擎该 URL 的相对重要程度)。 - 异步 + 注入请求:
getSitemapEntries接收request,可结合登录态、区域(参考 docs/decisions/009-region-selection.md)等上下文做差异化输出,也可以像示例一样直接查询数据库。 - 不要返回动态路由本身的 URL:注意示例返回的是
/blog/${blog.slug}这类具体内容页;/blog/:slug这样的动态占位路径不能直接进入 sitemap,必须在函数里展开成真实地址列表。
4.2 serverOnly$:把服务端逻辑从客户端构建中剥离
handle是路由的导出对象,同时存在于客户端与服务端构建中。而getSitemapEntries内部如果引用了数据库、密钥等仅服务端可用的代码,把它带进客户端 bundle 就会导致构建失败或泄露风险。
解决方案是vite-env-only提供的serverOnly$宏。Epic Stack 的 vite.config.ts 中已经预置了envOnlyMacros()插件:
import { envOnlyMacros } from 'vite-env-only' // ... plugins: [ // ... envOnlyMacros(), // ... ],在package.json中对应依赖为vite-env-only(当前仓库版本为^3.0.3)。编译时宏会把包裹的函数替换为空实现,保证只在服务端构建中保留真实逻辑。这也是handle这种"两端共享、逻辑不同"的导出的标准处理手法——凡是依赖 Node.js 运行时的 sitemap 逻辑,都应包上serverOnly$。
4.3 排除路由:让 URL 不出现在 sitemap 中
对于登录页、设置页等私有页面,只需让getSitemapEntries返回null(docs/seo.md 中的第二个示例,也是 3 节中仓库各路由的实际写法):
// in your routes/url-that-doesnt-need-sitemap import { type SEOHandle } from '@nasa-gcn/remix-seo' import { type Route } from './+types/sitemap[.]xml.ts' export async function loader({ request }: Route.LoaderArgs) { /**/ } export const handle: SEOHandle = { getSitemapEntries: () => null, }返回null即表示"该路由不生成任何站点地图条目"。这种写法无需serverOnly$——因为函数体内没有任何服务端专属逻辑,返回一个常量即可。
4.4 两者结合的仓库实况
在 Epic Stack 中,"排除"写法被大量使用(见 3 节清单),而"动态展开"写法对应getSitemapEntries的完整能力。一个完整的自定义策略可以是:营销落地页、关于页、隐私条款等公开页面保留默认收录;登录/注册/设置等私有页面返回null排除;内容列表通过异步查询动态生成条目并设置priority。你可以在 app/routes/_marketing/ 与 app/routes/_auth/ 目录下对比观察这两类路由的实现差异。
五、meta 标签与全局抓取开关
除 sitemap 外,Epic Stack 的 SEO 还包含两个值得配套使用的点。
5.1 按路由设置 meta
React Router 原生支持路由级meta导出。根路由 app/root.tsx 提供了一个全局兜底示例:
export const meta: Route.MetaFunction = ({ data }) => { return [ { title: data ? 'Epic Notes' : 'Error | Epic Notes' }, { name: 'description', content: `Your own captain's log` }, ] }MetaFunction接收路由loader的返回值data,据此动态切换页面标题与描述;任何子路由都可以导出自己的meta覆盖父级设置,实现每页独立的标题与描述。
5.2 ALLOW_INDEXING:一键开关全站收录
在 app/root.tsx 中,还有一个全局级别的抓取开关:
const allowIndexing = ENV.ALLOW_INDEXING !== 'false' // ... {allowIndexing ? null : ( <meta name="robots" content="noindex, nofollow" /> )}当环境变量ALLOW_INDEXING为'false'时,所有页面都会注入<meta name="robots" content="noindex, nofollow" />,阻止搜索引擎收录与跟踪。该变量在 app/utils/env.server.ts 中通过 Zod 校验,取值限定为'true' | 'false',可选。它非常适合预发布/沙箱环境:在不删除任何 sitemap 代码的前提下,一键让整站对搜索引擎"隐身",而生产环境只需设置ALLOW_INDEXING=true即可正常放行。
六、实践清单与注意事项
综合 docs/seo.md 与仓库源码,落地一套 Epic Stack SEO 配置时建议遵循以下清单:
- 确认反代透传:部署在反向代理/边缘网络后时,确认
X-Forwarded-Host与X-Forwarded-Proto正确传递,否则getDomainUrl推导出的站点地图域名会出错; - 审阅默认收录:
generateSitemap默认收录所有路由,新加路由后检查它是否是公开页面,私有页面务必补上getSitemapEntries: () => null; - 服务端逻辑必包宏:凡在
getSitemapEntries中使用数据库或 Node 专属 API,一律用serverOnly$包裹,客户端构建会自动剥离; - 动态路由展开:参数化路由(如
/notes/:noteId)不会自动生成真实 URL,需要在getSitemapEntries中查询数据后展开为具体路径,并可用priority表达内容优先级; - 配套使用缓存与开关:sitemap 响应自带
public, max-age=300缓存头可配合 CDN;非生产环境记得设置ALLOW_INDEXING=false阻止被索引。
这套方案的突出价值在于:sitemap 与 robots 的维护完全融入路由代码——路由即内容,路由即收录策略,既没有额外的中间件配置,也不存在运行时再同步的静态文件,搜索引擎可见性始终与你的代码变更保持一致。
【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考