在 Next.js App Router 中,Metadata API 把页面的标题、描述、Open Graph 卡片等元信息统一收口到一个 metadata 导出对象里。这个设计本身很好,但 TypeScript 用户尝试把 JSON-LD 结构化数据作为一个自定义字段塞进去时,立刻会碰到类型检查的报错。要理解这个报错,先要弄清楚 Next.js 在类型层面到底开放了哪些字段,以及为什么不给 JSON-LD 预留位置。

我们先来看一个典型的报错场景。你可能会这样写:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "产品详情",
description: "一个示例页面",
structuredData: {
"@context": "https://schema.org",
"@type": "Product",
name: "示例商品",
},
};
这时 TypeScript 会抛出类似 Object literal may only specify known properties 的错误,因为 Metadata 接口在 next 包里定义时并没有 structuredData 这个键。事实上,Next.js 的 Metadata 类型覆盖了 title、description、keywords、authors、openGraph、twitter、robots、icons、alternates、viewport 等常用的 SEO 相关字段,但并没有索引签名,也没有专门为任意 <script> 标签预留位置。这个限制并不是缺陷,而是为了约束元数据结构的稳定性,避免开发者随意往里面混入运行时才需要处理的数据。
通过模块扩充扩展 Metadata 类型
如果确实想把结构化数据放进 metadata 对象里,TypeScript 的声明合并提供了一种临时方案。你可以在项目根目录创建一个类型声明文件,通过 declare module 给 next 模块中的 Metadata 接口增加自定义字段。这样类型检查就会放过你定义的键。
// types/next-metadata.d.ts
import type { Metadata } from "next";
declare module "next" {
interface Metadata {
structuredData?: StructuredData[];
}
}
type StructuredData = {
"@context": "https://schema.org";
"@type": string;
name?: string;
[key: string]: unknown;
};
要使用这个扩充,需要确保 tsconfig.json 的 include 配置覆盖该声明文件所在目录。这种做法的好处是类型上完全可控,不会丢失代码提示。但它有一个明显的局限:Next.js 并不会因为 Metadata 类型里多了 structuredData 就自动把它渲染成 <script> 标签。也就是说,这只是骗过了编译器,运行时你仍然需要自己想办法把数据输出到页面头部,否则这个字段只会静静地躺在 metadata 对象里不产生任何效果。因此,扩充类型更适合用于一些不改变渲染行为的扩展字段,而不是真正需要注入 DOM 的场景。
用独立组件渲染 JSON-LD 结构化数据
更推荐的做法是把 JSON-LD 结构化数据交给一个专门的 React 组件去渲染。因为 Next.js 在 App Router 中会把组件返回的 <script> 标签保留在服务端渲染结果里,不会经过 metadata 对象的过滤。这样做类型边界清晰,也更容易复用和维护。
type JsonLdProps = {
data: Record<string, unknown>;
};
export function JsonLd({ data }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
使用这个组件时,你可以在 Server Component 中直接获取数据,然后将对象传给 JsonLd。由于 JsonLd 接收的是 Record<string, unknown>,它能容纳任意结构的 JSON-LD,同时不会污染 Metadata 类型。
import { JsonLd } from "@/components/json-ld";
export default function ProductPage() {
const product = {
"@context": "https://schema.org",
"@type": "Product",
name: "示例商品",
};
return (
<>
<JsonLd data={product} />
<main>页面内容</main>
</>
);
}
在 App Router 中,你可以把这个组件放在 layout 或 page 的 JSX 树中。Next.js 会在服务端渲染时识别这类脚本标签,并将其与页面其他头部资源一起输出。如果数据来自异步请求,也可以先 await 获取,再作为 props 传入。这样既保留了 TypeScript 的类型安全,又避免了把结构化数据硬塞进 metadata 对象带来的混乱。
动态 generateMetadata 中的类型处理
静态 metadata 导出的类型上限是 Metadata,动态生成元数据则依靠 generateMetadata 函数。很多开发者在写这个函数时容易直接用 any 标注 params 和 searchParams,这会让类型检查失去意义。实际上你可以根据路由结构定义一个精确的类型,让调用方获得完整的参数提示。
type PageProps = {
params: { slug: string };
searchParams: Record<string, string | string[] | undefined>;
};
export async function generateMetadata(
{ params, searchParams }: PageProps,
): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
type: "article",
images: [post.cover],
},
twitter: {
card: "summary_large_image",
title: post.title,
images: [post.cover],
},
};
}
这里 getPostBySlug 是一个假设的服务端数据获取函数,返回带有 title、excerpt、cover 等字段的对象。可以看到,generateMetadata 的返回值仍然遵守 Metadata 接口,因此 JSON-LD 并不能直接放到其中。这正是我们需要独立 JsonLd 组件的原因:generateMetadata 只负责常规 SEO 信息,结构化数据则由组件独立渲染。两者各司其职,类型定义也清晰可维护。
还有一点需要厘清:Next.js 内部确实存在 ResolvingMetadata 这样的辅助类型,但它并不是需要开发者手动导入或强记的工具。你只需要保证 generateMetadata 的返回类型标注为 Promise<Metadata> 或直接省略返回类型让编译器推导即可。把注意力放在参数类型的精确定义上,比纠结内部类型更有实际价值。
Next.js Metadata APITypeScript类型定义结构化数据修改时间:2026-09-20 16:58:35