如何基于统一配置为Next.js App Router和Remix v2生成SSR元标签?
在Monorepo中统一Next.js App Router与Remix v2的SEO元标签管理
1. 构建共享SEO工具包(Monorepo公共包)
在monorepo中新建公共包(比如@repo/seo),统一SEO元数据的结构、生成逻辑,彻底消除重复实现。
定义统一的SEO类型
创建packages/seo/src/types.ts:
export interface SeoMetadata { title: string; description: string; canonicalUrl?: string; robots: { index: boolean; follow: boolean; maxSnippet?: number; maxImagePreview?: 'none' | 'standard' | 'large'; maxVideoPreview?: number; }; openGraph: { title?: string; description?: string; images: Array<{ url: string; width?: number; height?: number; alt?: string; }>; url?: string; }; twitterCard: { cardType: 'summary' | 'summary_large_image' | 'app' | 'player'; title?: string; description?: string; image?: string; imageAlt?: string; }; } export interface PostSeoData { title: string; excerpt: string; coverImage: string; slug: string; canonicalUrl?: string; }
编写统一的SEO生成函数
创建packages/seo/src/generate.ts:
import { SeoMetadata, PostSeoData } from './types'; export function generatePostSeoMetadata(data: PostSeoData): SeoMetadata { const baseUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.REMIX_PUBLIC_APP_URL; const canonicalUrl = data.canonicalUrl || `${baseUrl}/blog/${data.slug}`; return { title: data.title, description: data.excerpt, canonicalUrl, robots: { index: true, follow: true, maxSnippet: 200, maxImagePreview: 'large', }, openGraph: { title: data.title, description: data.excerpt, images: [ { url: data.coverImage, width: 1200, height: 630, alt: data.title, }, ], url: canonicalUrl, }, twitterCard: { cardType: 'summary_large_image', title: data.title, description: data.excerpt, image: data.coverImage, imageAlt: data.title, }, }; }
2. Next.js App Router 适配
在Next.js应用中导入共享工具包,将统一格式的SEO数据转换为框架要求的结构:
// app/blog/[slug]/page.tsx import { generatePostSeoMetadata } from '@repo/seo'; import getPost from '@/lib/getPost'; export async function generateMetadata({ params }) { const post = await getPost(params.slug); const seo = generatePostSeoMetadata(post); return { title: seo.title, description: seo.description, alternates: { canonical: seo.canonicalUrl, }, robots: { index: seo.robots.index, follow: seo.robots.follow, maxSnippet: seo.robots.maxSnippet, maxImagePreview: seo.robots.maxImagePreview, maxVideoPreview: seo.robots.maxVideoPreview, }, openGraph: { title: seo.openGraph.title, description: seo.openGraph.description, images: seo.openGraph.images, url: seo.openGraph.url, }, twitter: { card: seo.twitterCard.cardType, title: seo.twitterCard.title, description: seo.twitterCard.description, images: [ { url: seo.twitterCard.image, alt: seo.twitterCard.imageAlt, }, ], }, }; }
3. Remix v2 适配
在Remix应用中同样导入共享工具包,将统一格式转换为框架要求的meta数组:
// app/routes/blog.$slug.tsx import { generatePostSeoMetadata } from '@repo/seo'; import { MetaFunction } from '@remix-run/node'; export const meta: MetaFunction = ({ data }) => { const seo = generatePostSeoMetadata(data.post); const metaTags = []; // 基础标签 metaTags.push({ title: seo.title }); metaTags.push({ name: 'description', content: seo.description }); if (seo.canonicalUrl) { metaTags.push({ rel: 'canonical', href: seo.canonicalUrl }); } // Robots指令拼接 const robotsParts = []; robotsParts.push(seo.robots.index ? 'index' : 'noindex'); robotsParts.push(seo.robots.follow ? 'follow' : 'nofollow'); if (seo.robots.maxSnippet) robotsParts.push(`max-snippet:${seo.robots.maxSnippet}`); if (seo.robots.maxImagePreview) robotsParts.push(`max-image-preview:${seo.robots.maxImagePreview}`); if (seo.robots.maxVideoPreview) robotsParts.push(`max-video-preview:${seo.robots.maxVideoPreview}`); metaTags.push({ name: 'robots', content: robotsParts.join(', ') }); // Open Graph标签 metaTags.push({ property: 'og:title', content: seo.openGraph.title || seo.title }); metaTags.push({ property: 'og:description', content: seo.openGraph.description || seo.description }); metaTags.push({ property: 'og:url', content: seo.openGraph.url || seo.canonicalUrl }); seo.openGraph.images.forEach((img, idx) => { metaTags.push({ property: idx === 0 ? 'og:image' : `og:image:${idx+1}`, content: img.url }); if (img.width) metaTags.push({ property: idx === 0 ? 'og:image:width' : `og:image:${idx+1}:width`, content: img.width.toString() }); if (img.height) metaTags.push({ property: idx === 0 ? 'og:image:height' : `og:image:${idx+1}:height`, content: img.height.toString() }); if (img.alt) metaTags.push({ property: idx === 0 ? 'og:image:alt' : `og:image:${idx+1}:alt`, content: img.alt }); }); // Twitter Card标签 metaTags.push({ name: 'twitter:card', content: seo.twitterCard.cardType }); metaTags.push({ name: 'twitter:title', content: seo.twitterCard.title || seo.title }); metaTags.push({ name: 'twitter:description', content: seo.twitterCard.description || seo.description }); if (seo.twitterCard.image) { metaTags.push({ name: 'twitter:image', content: seo.twitterCard.image }); if (seo.twitterCard.imageAlt) metaTags.push({ name: 'twitter:image:alt', content: seo.twitterCard.imageAlt }); } return metaTags; };
4. 维护与扩展
后续调整SEO规则(比如修改robots默认值、新增OG字段),只需修改@repo/seo中的生成逻辑,两个应用会自动同步更新,无需分别修改两套代码。
内容的提问来源于stack exchange,提问作者Al Amin
相关产品推荐
相关产品推荐

