diff --git a/docs/start/framework/react/guide/seo.md b/docs/start/framework/react/guide/seo.md
index a4f580d446b..3ee6ee46a97 100644
--- a/docs/start/framework/react/guide/seo.md
+++ b/docs/start/framework/react/guide/seo.md
@@ -22,6 +22,7 @@ TanStack Start gives you the building blocks for technical SEO:
- **Static Prerendering** - Pre-generates pages for optimal performance and crawlability
- **Document Head Management** - Full control over meta tags, titles, and structured data
- **Performance** - Fast load times through code-splitting, streaming, and optimal bundling
+- **[@tanstack/meta](#using-tanstackmeta)** - Composable, type-safe utilities for meta tags and JSON-LD
## Document Head Management
@@ -118,9 +119,135 @@ export const Route = createFileRoute('/posts/$postId')({
})
```
+## Using @tanstack/meta
+
+For a more streamlined approach to meta tag management, you can use the `@tanstack/meta` package. It provides composable, type-safe utilities that handle the 90% use case with a single function call.
+
+### Installation
+
+```bash
+npm install @tanstack/meta
+```
+
+### The createMeta Function
+
+The `createMeta` function generates a complete set of meta tags from a simple configuration:
+
+```tsx
+import { createFileRoute } from '@tanstack/react-router'
+import { createMeta } from '@tanstack/meta'
+
+export const Route = createFileRoute('/')({
+ head: () => ({
+ meta: createMeta({
+ title: 'My App - Home',
+ description: 'Welcome to My App, a platform for...',
+ }),
+ }),
+ component: HomePage,
+})
+```
+
+This single call generates:
+
+- ``
+- ``
+- `
My App - Home`
+- ``
+- Open Graph tags (`og:title`, `og:description`, `og:type`)
+- Twitter Card tags (`twitter:card`, `twitter:title`, `twitter:description`)
+
+### Full Example with Social Sharing
+
+```tsx
+import { createFileRoute } from '@tanstack/react-router'
+import { createMeta } from '@tanstack/meta'
+
+export const Route = createFileRoute('/posts/$postId')({
+ loader: async ({ params }) => {
+ const post = await fetchPost(params.postId)
+ return { post }
+ },
+ head: ({ loaderData }) => ({
+ meta: createMeta({
+ title: loaderData.post.title,
+ description: loaderData.post.excerpt,
+ url: `https://myapp.com/posts/${loaderData.post.id}`,
+ image: loaderData.post.coverImage,
+ type: 'article',
+ siteName: 'My App',
+ twitterSite: '@myapp',
+ }),
+ }),
+ component: PostPage,
+})
+```
+
+### Extending with Custom Meta
+
+You can extend the generated meta with custom tags:
+
+```tsx
+import { createMeta } from '@tanstack/meta'
+
+head: () => ({
+ meta: createMeta({
+ title: 'My Page',
+ description: 'Page description',
+ extend: [
+ { name: 'author', content: 'John Doe' },
+ { name: 'keywords', content: 'react, tanstack, router' },
+ ],
+ }),
+})
+```
+
+### Using Individual Builders
+
+For more control, use the individual builder functions through the `meta` namespace:
+
+```tsx
+import { meta } from '@tanstack/meta'
+
+head: () => ({
+ meta: [
+ ...meta.title('My Page', '%s | My App'), // With template
+ ...meta.description('Page description'),
+ ...meta.robots({ index: true, follow: true }),
+ ...meta.openGraph({
+ title: 'My Page',
+ type: 'website',
+ image: 'https://myapp.com/og.jpg',
+ }),
+ ...meta.twitter({
+ card: 'summary_large_image',
+ site: '@myapp',
+ }),
+ ],
+})
+```
+
+### Available Builders
+
+| Builder | Description |
+|---------|-------------|
+| `meta.title(value, template?)` | Page title with optional template |
+| `meta.description(content)` | Meta description |
+| `meta.charset()` | UTF-8 charset |
+| `meta.viewport(content?)` | Viewport configuration |
+| `meta.robots(config)` | Robot directives (index, follow, etc.) |
+| `meta.canonical(href)` | Canonical URL link |
+| `meta.alternate(links)` | Alternate language links |
+| `meta.openGraph(config)` | Open Graph meta tags |
+| `meta.twitter(config)` | Twitter Card meta tags |
+| `meta.themeColor(color)` | Theme color (supports light/dark) |
+| `meta.verification(config)` | Search engine verification codes |
+
## Structured Data (JSON-LD)
-Structured data helps search engines understand your content and can enable rich results in search:
+Structured data helps search engines understand your content and can enable rich results in search.
+
+### Manual Approach
```tsx
export const Route = createFileRoute('/posts/$postId')({
@@ -152,6 +279,106 @@ export const Route = createFileRoute('/posts/$postId')({
})
```
+### Using @tanstack/meta/json-ld
+
+The `@tanstack/meta/json-ld` subpath provides type-safe builders for common Schema.org types:
+
+```tsx
+import { createFileRoute } from '@tanstack/react-router'
+import { createMeta } from '@tanstack/meta'
+import { jsonLd } from '@tanstack/meta/json-ld'
+
+export const Route = createFileRoute('/posts/$postId')({
+ loader: async ({ params }) => {
+ const post = await fetchPost(params.postId)
+ return { post }
+ },
+ head: ({ loaderData }) => ({
+ meta: [
+ ...createMeta({
+ title: loaderData.post.title,
+ description: loaderData.post.excerpt,
+ url: `https://myapp.com/posts/${loaderData.post.id}`,
+ image: loaderData.post.coverImage,
+ type: 'article',
+ }),
+ ...jsonLd.article({
+ headline: loaderData.post.title,
+ description: loaderData.post.excerpt,
+ author: loaderData.post.author.name,
+ datePublished: loaderData.post.publishedAt,
+ image: loaderData.post.coverImage,
+ }),
+ ],
+ }),
+ component: PostPage,
+})
+```
+
+### Available JSON-LD Builders
+
+| Builder | Description |
+|---------|-------------|
+| `jsonLd.website(config)` | WebSite schema with optional search action |
+| `jsonLd.organization(config)` | Organization with logo, socials, address |
+| `jsonLd.person(config)` | Person schema |
+| `jsonLd.article(config)` | Article, BlogPosting, NewsArticle |
+| `jsonLd.product(config)` | Product with price, availability, ratings |
+| `jsonLd.breadcrumbs(items)` | BreadcrumbList for navigation |
+| `jsonLd.faq(items)` | FAQPage with questions and answers |
+| `jsonLd.event(config)` | Event with location, dates |
+| `jsonLd.localBusiness(config)` | LocalBusiness, Restaurant, Store |
+| `jsonLd.softwareApp(config)` | SoftwareApplication |
+| `jsonLd.video(config)` | VideoObject |
+| `jsonLd.recipe(config)` | Recipe with ingredients, instructions |
+| `jsonLd.course(config)` | Course with provider |
+| `jsonLd.howTo(config)` | HowTo with steps |
+| `jsonLd.create(schema)` | Raw JSON-LD for any schema type |
+
+### Product Page Example
+
+```tsx
+import { createMeta } from '@tanstack/meta'
+import { jsonLd } from '@tanstack/meta/json-ld'
+
+export const Route = createFileRoute('/products/$productId')({
+ loader: async ({ params }) => {
+ const product = await fetchProduct(params.productId)
+ return { product }
+ },
+ head: ({ loaderData }) => ({
+ meta: [
+ ...createMeta({
+ title: loaderData.product.name,
+ description: loaderData.product.description,
+ url: `https://myapp.com/products/${loaderData.product.id}`,
+ image: loaderData.product.image,
+ type: 'product',
+ }),
+ ...jsonLd.product({
+ name: loaderData.product.name,
+ description: loaderData.product.description,
+ image: loaderData.product.image,
+ price: loaderData.product.price,
+ currency: 'USD',
+ availability: 'InStock',
+ brand: loaderData.product.brand,
+ rating: {
+ value: loaderData.product.rating,
+ count: loaderData.product.reviewCount,
+ },
+ }),
+ ...jsonLd.breadcrumbs([
+ { name: 'Home', url: 'https://myapp.com' },
+ { name: 'Products', url: 'https://myapp.com/products' },
+ { name: loaderData.product.name, url: `https://myapp.com/products/${loaderData.product.id}` },
+ ]),
+ ],
+ }),
+ component: ProductPage,
+})
+```
+
## Server-Side Rendering
SSR is enabled by default in TanStack Start. This ensures that search engine crawlers receive fully rendered HTML content, which is critical for SEO.
diff --git a/examples/react/start-basic-react-query/package.json b/examples/react/start-basic-react-query/package.json
index 86cf44d3703..e78858a6923 100644
--- a/examples/react/start-basic-react-query/package.json
+++ b/examples/react/start-basic-react-query/package.json
@@ -10,6 +10,7 @@
"start": "pnpx srvx --prod -s ../client dist/server/server.js"
},
"dependencies": {
+ "@tanstack/meta": "workspace:*",
"@tanstack/react-query": "^5.90.0",
"@tanstack/react-query-devtools": "^5.90.0",
"@tanstack/react-router": "^1.144.0",
diff --git a/examples/react/start-basic-react-query/src/routes/__root.tsx b/examples/react/start-basic-react-query/src/routes/__root.tsx
index bc278f8036b..b2ef4c07302 100644
--- a/examples/react/start-basic-react-query/src/routes/__root.tsx
+++ b/examples/react/start-basic-react-query/src/routes/__root.tsx
@@ -8,31 +8,25 @@ import {
} from '@tanstack/react-router'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
+import { createMeta } from '@tanstack/meta'
import * as React from 'react'
import type { QueryClient } from '@tanstack/react-query'
import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary'
import { NotFound } from '~/components/NotFound'
import appCss from '~/styles/app.css?url'
-import { seo } from '~/utils/seo'
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
}>()({
head: () => ({
- meta: [
- {
- charSet: 'utf-8',
- },
- {
- name: 'viewport',
- content: 'width=device-width, initial-scale=1',
- },
- ...seo({
- title:
- 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework',
- description: `TanStack Start is a type-safe, client-first, full-stack React framework. `,
- }),
- ],
+ meta: createMeta({
+ title:
+ 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework',
+ description:
+ 'TanStack Start is a type-safe, client-first, full-stack React framework.',
+ twitterCreator: '@tanaborlabs',
+ twitterSite: '@tanaborlabs',
+ }),
links: [
{ rel: 'stylesheet', href: appCss },
{
diff --git a/examples/react/start-basic-react-query/src/routes/posts.$postId.tsx b/examples/react/start-basic-react-query/src/routes/posts.$postId.tsx
index e9e58e55411..4f211f9a7fa 100644
--- a/examples/react/start-basic-react-query/src/routes/posts.$postId.tsx
+++ b/examples/react/start-basic-react-query/src/routes/posts.$postId.tsx
@@ -1,5 +1,6 @@
import { ErrorComponent, Link, createFileRoute } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
+import { createMeta } from '@tanstack/meta'
import { postQueryOptions } from '../utils/posts'
import type { ErrorComponentProps } from '@tanstack/react-router'
import { NotFound } from '~/components/NotFound'
@@ -12,10 +13,23 @@ export const Route = createFileRoute('/posts/$postId')({
return {
title: data.title,
+ body: data.body,
+ id: data.id,
}
},
head: ({ loaderData }) => ({
- meta: loaderData ? [{ title: loaderData.title }] : undefined,
+ meta: loaderData
+ ? createMeta({
+ title: loaderData.title,
+ description: loaderData.body.slice(0, 160),
+ // In a real app, you'd use an actual URL
+ url: `https://example.com/posts/${loaderData.id}`,
+ titleTemplate: '%s | TanStack Start',
+ // Disable charset/viewport since root already provides them
+ charset: false,
+ viewport: false,
+ })
+ : undefined,
}),
errorComponent: PostErrorComponent,
notFoundComponent: () => {
diff --git a/examples/react/start-basic-react-query/src/utils/seo.ts b/examples/react/start-basic-react-query/src/utils/seo.ts
deleted file mode 100644
index d18ad84b74e..00000000000
--- a/examples/react/start-basic-react-query/src/utils/seo.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-export const seo = ({
- title,
- description,
- keywords,
- image,
-}: {
- title: string
- description?: string
- image?: string
- keywords?: string
-}) => {
- const tags = [
- { title },
- { name: 'description', content: description },
- { name: 'keywords', content: keywords },
- { name: 'twitter:title', content: title },
- { name: 'twitter:description', content: description },
- { name: 'twitter:creator', content: '@tannerlinsley' },
- { name: 'twitter:site', content: '@tannerlinsley' },
- { name: 'og:type', content: 'website' },
- { name: 'og:title', content: title },
- { name: 'og:description', content: description },
- ...(image
- ? [
- { name: 'twitter:image', content: image },
- { name: 'twitter:card', content: 'summary_large_image' },
- { name: 'og:image', content: image },
- ]
- : []),
- ]
-
- return tags
-}
diff --git a/packages/meta/package.json b/packages/meta/package.json
new file mode 100644
index 00000000000..b2c1e872e96
--- /dev/null
+++ b/packages/meta/package.json
@@ -0,0 +1,79 @@
+{
+ "name": "@tanstack/meta",
+ "version": "0.0.1",
+ "description": "Composable, type-safe meta tag and JSON-LD utilities for modern web applications",
+ "author": "Tanner Linsley",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/router.git",
+ "directory": "packages/meta"
+ },
+ "homepage": "https://tanstack.com/router",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "keywords": [
+ "meta",
+ "seo",
+ "json-ld",
+ "structured-data",
+ "open-graph",
+ "twitter-cards",
+ "typescript"
+ ],
+ "scripts": {
+ "clean": "rimraf ./dist && rimraf ./coverage",
+ "test:eslint": "eslint ./src",
+ "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"",
+ "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js",
+ "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js",
+ "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js",
+ "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js",
+ "test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js",
+ "test:types:ts59": "tsc",
+ "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .",
+ "test:unit": "vitest",
+ "test:unit:dev": "pnpm run test:unit --watch",
+ "build": "vite build"
+ },
+ "type": "module",
+ "types": "dist/esm/index.d.ts",
+ "main": "dist/cjs/index.cjs",
+ "module": "dist/esm/index.js",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/esm/index.d.ts",
+ "default": "./dist/esm/index.js"
+ },
+ "require": {
+ "types": "./dist/cjs/index.d.cts",
+ "default": "./dist/cjs/index.cjs"
+ }
+ },
+ "./json-ld": {
+ "import": {
+ "types": "./dist/esm/json-ld/index.d.ts",
+ "default": "./dist/esm/json-ld/index.js"
+ },
+ "require": {
+ "types": "./dist/cjs/json-ld/index.d.cts",
+ "default": "./dist/cjs/json-ld/index.cjs"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "sideEffects": false,
+ "files": [
+ "dist",
+ "src"
+ ],
+ "engines": {
+ "node": ">=12"
+ },
+ "devDependencies": {
+ "esbuild": "^0.25.0"
+ }
+}
diff --git a/packages/meta/src/builders.ts b/packages/meta/src/builders.ts
new file mode 100644
index 00000000000..dec139d1e0e
--- /dev/null
+++ b/packages/meta/src/builders.ts
@@ -0,0 +1,321 @@
+import type { MetaDescriptor, MetaImage, RobotsConfig } from './types'
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Core Builders
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Creates a title meta descriptor
+ *
+ * @example
+ * ```ts
+ * meta.title('My Page')
+ * meta.title('My Page', '%s | My Site') // With template
+ * ```
+ */
+export function title(
+ value: string,
+ template?: string,
+): Array {
+ const formatted = template ? template.replace('%s', value) : value
+ return [{ title: formatted }]
+}
+
+/**
+ * Creates a description meta descriptor
+ *
+ * @example
+ * ```ts
+ * meta.description('Page description here')
+ * ```
+ */
+export function description(content: string): Array {
+ return [{ name: 'description', content }]
+}
+
+/**
+ * Creates a charset meta descriptor (UTF-8)
+ */
+export function charset(): Array {
+ return [{ charSet: 'utf-8' }]
+}
+
+/**
+ * Creates a viewport meta descriptor
+ *
+ * @example
+ * ```ts
+ * meta.viewport() // Default: width=device-width, initial-scale=1
+ * meta.viewport('width=device-width, initial-scale=1, maximum-scale=5')
+ * ```
+ */
+export function viewport(
+ content: string = 'width=device-width, initial-scale=1',
+): Array {
+ return [{ name: 'viewport', content }]
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SEO Builders
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Creates robot directive meta descriptor
+ *
+ * @example
+ * ```ts
+ * meta.robots({ index: true, follow: true })
+ * meta.robots({ index: false }) // noindex
+ * meta.robots({ maxSnippet: 160, maxImagePreview: 'large' })
+ * ```
+ */
+export function robots(config: RobotsConfig): Array {
+ const directives: Array = []
+
+ if (config.index === false) directives.push('noindex')
+ else if (config.index === true) directives.push('index')
+
+ if (config.follow === false) directives.push('nofollow')
+ else if (config.follow === true) directives.push('follow')
+
+ if (config.noarchive) directives.push('noarchive')
+ if (config.nosnippet) directives.push('nosnippet')
+ if (config.maxSnippet !== undefined)
+ directives.push(`max-snippet:${config.maxSnippet}`)
+ if (config.maxImagePreview)
+ directives.push(`max-image-preview:${config.maxImagePreview}`)
+
+ if (directives.length === 0) return []
+
+ return [{ name: 'robots', content: directives.join(', ') }]
+}
+
+/**
+ * Creates a canonical link descriptor
+ *
+ * @example
+ * ```ts
+ * meta.canonical('https://example.com/page')
+ * ```
+ */
+export function canonical(href: string): Array {
+ return [{ tagName: 'link', rel: 'canonical', href }]
+}
+
+/**
+ * Creates alternate language link descriptors
+ *
+ * @example
+ * ```ts
+ * meta.alternate([
+ * { lang: 'en', href: 'https://example.com/en/page' },
+ * { lang: 'es', href: 'https://example.com/es/page' },
+ * { lang: 'x-default', href: 'https://example.com/page' },
+ * ])
+ * ```
+ */
+export function alternate(
+ links: Array<{ lang: string; href: string }>,
+): Array {
+ return links.map((link) => ({
+ tagName: 'link',
+ rel: 'alternate',
+ hreflang: link.lang,
+ href: link.href,
+ }))
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Open Graph Builders
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Creates Open Graph meta descriptors
+ *
+ * @example
+ * ```ts
+ * meta.openGraph({
+ * title: 'My Page',
+ * description: 'Page description',
+ * type: 'website',
+ * url: 'https://example.com/page',
+ * image: 'https://example.com/og.jpg',
+ * })
+ * ```
+ */
+export function openGraph(config: {
+ title?: string
+ description?: string
+ type?: string
+ url?: string
+ siteName?: string
+ locale?: string
+ image?: string | MetaImage
+ images?: Array
+}): Array {
+ const meta: Array = []
+
+ if (config.title) meta.push({ property: 'og:title', content: config.title })
+ if (config.description)
+ meta.push({ property: 'og:description', content: config.description })
+ if (config.type) meta.push({ property: 'og:type', content: config.type })
+ if (config.url) meta.push({ property: 'og:url', content: config.url })
+ if (config.siteName)
+ meta.push({ property: 'og:site_name', content: config.siteName })
+ if (config.locale)
+ meta.push({ property: 'og:locale', content: config.locale })
+
+ const images = config.images ?? (config.image ? [config.image] : [])
+ for (const img of images) {
+ if (typeof img === 'string') {
+ meta.push({ property: 'og:image', content: img })
+ } else {
+ meta.push({ property: 'og:image', content: img.url })
+ if (img.width)
+ meta.push({ property: 'og:image:width', content: String(img.width) })
+ if (img.height)
+ meta.push({ property: 'og:image:height', content: String(img.height) })
+ if (img.alt) meta.push({ property: 'og:image:alt', content: img.alt })
+ }
+ }
+
+ return meta
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Twitter Card Builders
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Creates Twitter Card meta descriptors
+ *
+ * @example
+ * ```ts
+ * meta.twitter({
+ * card: 'summary_large_image',
+ * title: 'My Page',
+ * description: 'Page description',
+ * image: 'https://example.com/twitter.jpg',
+ * site: '@mysite',
+ * })
+ * ```
+ */
+export function twitter(config: {
+ card?: 'summary' | 'summary_large_image' | 'app' | 'player'
+ title?: string
+ description?: string
+ image?: string
+ imageAlt?: string
+ site?: string
+ creator?: string
+}): Array {
+ const meta: Array = []
+
+ if (config.card) meta.push({ name: 'twitter:card', content: config.card })
+ if (config.title) meta.push({ name: 'twitter:title', content: config.title })
+ if (config.description)
+ meta.push({ name: 'twitter:description', content: config.description })
+ if (config.image) meta.push({ name: 'twitter:image', content: config.image })
+ if (config.imageAlt)
+ meta.push({ name: 'twitter:image:alt', content: config.imageAlt })
+ if (config.site) meta.push({ name: 'twitter:site', content: config.site })
+ if (config.creator)
+ meta.push({ name: 'twitter:creator', content: config.creator })
+
+ return meta
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Utility Builders
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Creates theme-color meta descriptor(s)
+ *
+ * @example
+ * ```ts
+ * meta.themeColor('#ffffff')
+ * meta.themeColor({ light: '#ffffff', dark: '#000000' })
+ * ```
+ */
+export function themeColor(
+ color: string | { light: string; dark: string },
+): Array {
+ if (typeof color === 'string') {
+ return [{ name: 'theme-color', content: color }]
+ }
+ return [
+ {
+ name: 'theme-color',
+ content: color.light,
+ media: '(prefers-color-scheme: light)',
+ } as MetaDescriptor,
+ {
+ name: 'theme-color',
+ content: color.dark,
+ media: '(prefers-color-scheme: dark)',
+ } as MetaDescriptor,
+ ]
+}
+
+/**
+ * Creates verification meta descriptors for search engines
+ *
+ * @example
+ * ```ts
+ * meta.verification({ google: 'code', bing: 'code' })
+ * ```
+ */
+export function verification(config: {
+ google?: string
+ bing?: string
+ yandex?: string
+ pinterest?: string
+}): Array {
+ const meta: Array = []
+
+ if (config.google)
+ meta.push({ name: 'google-site-verification', content: config.google })
+ if (config.bing) meta.push({ name: 'msvalidate.01', content: config.bing })
+ if (config.yandex)
+ meta.push({ name: 'yandex-verification', content: config.yandex })
+ if (config.pinterest)
+ meta.push({ name: 'p:domain_verify', content: config.pinterest })
+
+ return meta
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Meta Namespace Object
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Namespace object containing all meta builders.
+ * Provides a convenient way to access builders with autocomplete.
+ *
+ * @example
+ * ```ts
+ * import { meta } from '@tanstack/meta'
+ *
+ * head: () => ({
+ * meta: [
+ * ...meta.title('My Page'),
+ * ...meta.description('Description'),
+ * ...meta.openGraph({ title: 'My Page', type: 'website' }),
+ * ],
+ * })
+ * ```
+ */
+export const meta = {
+ title,
+ description,
+ charset,
+ viewport,
+ robots,
+ canonical,
+ alternate,
+ openGraph,
+ twitter,
+ themeColor,
+ verification,
+} as const
diff --git a/packages/meta/src/createMeta.ts b/packages/meta/src/createMeta.ts
new file mode 100644
index 00000000000..74430d86894
--- /dev/null
+++ b/packages/meta/src/createMeta.ts
@@ -0,0 +1,423 @@
+import type {
+ MetaDescriptor,
+ MetaImage,
+ OpenGraphConfig,
+ RobotsConfig,
+ TwitterConfig,
+} from './types'
+
+/**
+ * Configuration for createMeta - the main entry point for generating meta tags.
+ *
+ * @example
+ * ```ts
+ * // Basic usage - covers 90% of use cases
+ * createMeta({
+ * title: 'My Page',
+ * description: 'A great page about something',
+ * })
+ *
+ * // Full configuration
+ * createMeta({
+ * title: 'Product Name',
+ * description: 'Product description',
+ * url: 'https://example.com/product',
+ * image: 'https://example.com/og.jpg',
+ * siteName: 'My Store',
+ * type: 'product',
+ * })
+ * ```
+ */
+export interface CreateMetaConfig {
+ // ─────────────────────────────────────────────────────────────────────────
+ // Required - The bare minimum for any page
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Page title - used for , og:title, and twitter:title
+ */
+ title: string
+
+ /**
+ * Page description - used for description, og:description, and twitter:description
+ */
+ description: string
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Recommended - Significantly improves SEO and social sharing
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Canonical URL of the page - used for canonical link and og:url
+ */
+ url?: string
+
+ /**
+ * Primary image for social sharing - used for og:image and twitter:image
+ * Can be a URL string or an object with dimensions
+ *
+ * @example
+ * ```ts
+ * // Simple URL
+ * image: 'https://example.com/og.jpg'
+ *
+ * // With dimensions (recommended for better rendering)
+ * image: {
+ * url: 'https://example.com/og.jpg',
+ * width: 1200,
+ * height: 630,
+ * alt: 'Description of image'
+ * }
+ * ```
+ */
+ image?: string | MetaImage
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Optional - Fine-tune behavior
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Template for the title, use %s as placeholder
+ * @example '%s | My Site' results in 'Page Title | My Site'
+ */
+ titleTemplate?: string
+
+ /**
+ * Name of the website/application
+ */
+ siteName?: string
+
+ /**
+ * Content type for Open Graph
+ * @default 'website'
+ */
+ type?: 'website' | 'article' | 'product' | 'profile' | string
+
+ /**
+ * Locale for Open Graph (e.g., 'en_US')
+ */
+ locale?: string
+
+ /**
+ * Twitter @username for the website
+ */
+ twitterSite?: string
+
+ /**
+ * Twitter @username of the content creator
+ */
+ twitterCreator?: string
+
+ /**
+ * Theme color for browser chrome
+ * Can be a single color or light/dark mode colors
+ *
+ * @example
+ * ```ts
+ * themeColor: '#ffffff'
+ * // or
+ * themeColor: { light: '#ffffff', dark: '#000000' }
+ * ```
+ */
+ themeColor?: string | { light: string; dark: string }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Overrides - Full control when you need it
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Override robot directives
+ * @default { index: true, follow: true }
+ */
+ robots?: RobotsConfig
+
+ /**
+ * Override Open Graph properties
+ * Values here will override the inferred ones
+ */
+ openGraph?: OpenGraphConfig
+
+ /**
+ * Override Twitter Card properties
+ * Values here will override the inferred ones
+ */
+ twitter?: TwitterConfig
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Control what's included
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Include charset meta tag
+ * @default true
+ */
+ charset?: boolean
+
+ /**
+ * Include viewport meta tag
+ * @default true
+ */
+ viewport?: boolean | string
+
+ /**
+ * Include canonical link
+ * @default true when url is provided
+ */
+ canonical?: boolean
+
+ /**
+ * Additional meta descriptors to append
+ * These are added after the generated ones
+ */
+ extend?: Array
+}
+
+/**
+ * Creates a complete set of meta tags for a page.
+ *
+ * This is the primary API for @tanstack/meta - it handles the 90% use case
+ * of generating proper meta tags for SEO and social sharing.
+ *
+ * @example
+ * ```ts
+ * import { createMeta } from '@tanstack/meta'
+ *
+ * // In a TanStack Router route
+ * export const Route = createFileRoute('/about')({
+ * head: () => ({
+ * meta: createMeta({
+ * title: 'About Us',
+ * description: 'Learn about our company',
+ * url: 'https://example.com/about',
+ * image: 'https://example.com/about-og.jpg',
+ * }),
+ * }),
+ * })
+ * ```
+ *
+ * @example
+ * ```ts
+ * // Extending with custom meta or JSON-LD
+ * import { createMeta } from '@tanstack/meta'
+ * import { jsonLd } from '@tanstack/meta/json-ld'
+ *
+ * head: () => ({
+ * meta: [
+ * ...createMeta({
+ * title: 'Product Name',
+ * description: 'Product description',
+ * }),
+ * ...jsonLd.product({
+ * name: 'Product Name',
+ * price: 99.99,
+ * currency: 'USD',
+ * }),
+ * ],
+ * })
+ * ```
+ *
+ * @returns Array of MetaDescriptor objects
+ */
+export function createMeta(config: CreateMetaConfig): Array {
+ const {
+ title,
+ description,
+ url,
+ image,
+ titleTemplate,
+ siteName,
+ type = 'website',
+ locale,
+ twitterSite,
+ twitterCreator,
+ themeColor,
+ robots,
+ openGraph,
+ twitter,
+ charset = true,
+ viewport = true,
+ canonical = true,
+ extend,
+ } = config
+
+ const meta: Array = []
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Essential meta tags
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // Charset
+ if (charset) {
+ meta.push({ charSet: 'utf-8' })
+ }
+
+ // Viewport
+ if (viewport) {
+ const viewportContent =
+ typeof viewport === 'string'
+ ? viewport
+ : 'width=device-width, initial-scale=1'
+ meta.push({ name: 'viewport', content: viewportContent })
+ }
+
+ // Title
+ const formattedTitle = titleTemplate
+ ? titleTemplate.replace('%s', title)
+ : title
+ meta.push({ title: formattedTitle })
+
+ // Description
+ meta.push({ name: 'description', content: description })
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Robots
+ // ─────────────────────────────────────────────────────────────────────────
+
+ if (robots) {
+ const directives: Array = []
+
+ if (robots.index === false) directives.push('noindex')
+ else if (robots.index === true) directives.push('index')
+
+ if (robots.follow === false) directives.push('nofollow')
+ else if (robots.follow === true) directives.push('follow')
+
+ if (robots.noarchive) directives.push('noarchive')
+ if (robots.nosnippet) directives.push('nosnippet')
+ if (robots.maxSnippet !== undefined)
+ directives.push(`max-snippet:${robots.maxSnippet}`)
+ if (robots.maxImagePreview)
+ directives.push(`max-image-preview:${robots.maxImagePreview}`)
+
+ if (directives.length > 0) {
+ meta.push({ name: 'robots', content: directives.join(', ') })
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Canonical URL
+ // ─────────────────────────────────────────────────────────────────────────
+
+ if (url && canonical) {
+ meta.push({ tagName: 'link', rel: 'canonical', href: url })
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Open Graph
+ // ─────────────────────────────────────────────────────────────────────────
+
+ const ogTitle = openGraph?.title ?? title
+ const ogDescription = openGraph?.description ?? description
+ const ogType = openGraph?.type ?? type
+ const ogUrl = openGraph?.url ?? url
+ const ogSiteName = openGraph?.siteName ?? siteName
+ const ogLocale = openGraph?.locale ?? locale
+ const ogImages = openGraph?.images ?? (image ? [image] : [])
+
+ meta.push({ property: 'og:title', content: ogTitle })
+ meta.push({ property: 'og:description', content: ogDescription })
+ meta.push({ property: 'og:type', content: ogType })
+
+ if (ogUrl) meta.push({ property: 'og:url', content: ogUrl })
+ if (ogSiteName) meta.push({ property: 'og:site_name', content: ogSiteName })
+ if (ogLocale) meta.push({ property: 'og:locale', content: ogLocale })
+
+ for (const img of ogImages) {
+ if (typeof img === 'string') {
+ meta.push({ property: 'og:image', content: img })
+ } else {
+ meta.push({ property: 'og:image', content: img.url })
+ if (img.width)
+ meta.push({ property: 'og:image:width', content: String(img.width) })
+ if (img.height)
+ meta.push({ property: 'og:image:height', content: String(img.height) })
+ if (img.alt) meta.push({ property: 'og:image:alt', content: img.alt })
+ }
+ }
+
+ // Article-specific properties
+ if (openGraph?.article) {
+ const article = openGraph.article
+ if (article.publishedTime) {
+ meta.push({
+ property: 'article:published_time',
+ content: article.publishedTime,
+ })
+ }
+ if (article.modifiedTime) {
+ meta.push({
+ property: 'article:modified_time',
+ content: article.modifiedTime,
+ })
+ }
+ if (article.section) {
+ meta.push({ property: 'article:section', content: article.section })
+ }
+ if (article.authors) {
+ for (const author of article.authors) {
+ meta.push({ property: 'article:author', content: author })
+ }
+ }
+ if (article.tags) {
+ for (const tag of article.tags) {
+ meta.push({ property: 'article:tag', content: tag })
+ }
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Twitter Card
+ // ─────────────────────────────────────────────────────────────────────────
+
+ const hasImage = ogImages.length > 0
+ const twitterCard = twitter?.card ?? (hasImage ? 'summary_large_image' : 'summary')
+ const twitterTitle = twitter?.title ?? title
+ const twitterDescription = twitter?.description ?? description
+ const twitterImage = twitter?.image ?? (typeof image === 'string' ? image : image?.url)
+ const twitterImageAlt = twitter?.imageAlt ?? (typeof image === 'object' ? image?.alt : undefined)
+ const twitterSiteHandle = twitter?.site ?? twitterSite
+ const twitterCreatorHandle = twitter?.creator ?? twitterCreator
+
+ meta.push({ name: 'twitter:card', content: twitterCard })
+ meta.push({ name: 'twitter:title', content: twitterTitle })
+ meta.push({ name: 'twitter:description', content: twitterDescription })
+
+ if (twitterImage) meta.push({ name: 'twitter:image', content: twitterImage })
+ if (twitterImageAlt)
+ meta.push({ name: 'twitter:image:alt', content: twitterImageAlt })
+ if (twitterSiteHandle)
+ meta.push({ name: 'twitter:site', content: twitterSiteHandle })
+ if (twitterCreatorHandle)
+ meta.push({ name: 'twitter:creator', content: twitterCreatorHandle })
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Theme Color
+ // ─────────────────────────────────────────────────────────────────────────
+
+ if (themeColor) {
+ if (typeof themeColor === 'string') {
+ meta.push({ name: 'theme-color', content: themeColor })
+ } else {
+ meta.push({
+ name: 'theme-color',
+ content: themeColor.light,
+ media: '(prefers-color-scheme: light)',
+ } as MetaDescriptor)
+ meta.push({
+ name: 'theme-color',
+ content: themeColor.dark,
+ media: '(prefers-color-scheme: dark)',
+ } as MetaDescriptor)
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Extensions
+ // ─────────────────────────────────────────────────────────────────────────
+
+ if (extend) {
+ meta.push(...extend)
+ }
+
+ return meta
+}
diff --git a/packages/meta/src/index.ts b/packages/meta/src/index.ts
new file mode 100644
index 00000000000..91037e3af44
--- /dev/null
+++ b/packages/meta/src/index.ts
@@ -0,0 +1,98 @@
+/**
+ * @tanstack/meta
+ *
+ * Composable, type-safe meta tag utilities for modern web applications.
+ *
+ * ## Quick Start
+ *
+ * ```ts
+ * import { createMeta } from '@tanstack/meta'
+ *
+ * // In a TanStack Router route
+ * export const Route = createFileRoute('/about')({
+ * head: () => ({
+ * meta: createMeta({
+ * title: 'About Us',
+ * description: 'Learn about our company',
+ * url: 'https://example.com/about',
+ * image: 'https://example.com/about-og.jpg',
+ * }),
+ * }),
+ * })
+ * ```
+ *
+ * ## Extending with JSON-LD
+ *
+ * ```ts
+ * import { createMeta } from '@tanstack/meta'
+ * import { jsonLd } from '@tanstack/meta/json-ld'
+ *
+ * head: () => ({
+ * meta: [
+ * ...createMeta({ title: 'Product', description: 'Great product' }),
+ * ...jsonLd.product({ name: 'Product', price: 99.99 }),
+ * ],
+ * })
+ * ```
+ *
+ * ## Using Individual Builders
+ *
+ * ```ts
+ * import { meta } from '@tanstack/meta'
+ *
+ * head: () => ({
+ * meta: [
+ * ...meta.title('My Page'),
+ * ...meta.description('Page description'),
+ * ...meta.robots({ index: true, follow: true }),
+ * ],
+ * })
+ * ```
+ */
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Primary API
+// ─────────────────────────────────────────────────────────────────────────────
+
+export { createMeta } from './createMeta'
+export type { CreateMetaConfig } from './createMeta'
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Individual Builders
+// ─────────────────────────────────────────────────────────────────────────────
+
+export {
+ // Builder namespace
+ meta,
+ // Individual functions for tree-shaking
+ title,
+ description,
+ charset,
+ viewport,
+ robots,
+ canonical,
+ alternate,
+ openGraph,
+ twitter,
+ themeColor,
+ verification,
+} from './builders'
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Merge Utilities
+// ─────────────────────────────────────────────────────────────────────────────
+
+export { mergeMeta, mergeMetaWith, excludeMeta, pickMeta } from './merge'
+export type { MergeStrategy, MergeOptions } from './merge'
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+export type {
+ MetaDescriptor,
+ MetaImage,
+ RobotsConfig,
+ OpenGraphConfig,
+ TwitterConfig,
+} from './types'
diff --git a/packages/meta/src/json-ld/builders.ts b/packages/meta/src/json-ld/builders.ts
new file mode 100644
index 00000000000..701ca0dc8f0
--- /dev/null
+++ b/packages/meta/src/json-ld/builders.ts
@@ -0,0 +1,807 @@
+import type { MetaDescriptor } from '../types'
+import type {
+ Article,
+ Course,
+ Event,
+ FAQPage,
+ HowTo,
+ LocalBusiness,
+ Organization,
+ Person,
+ Product,
+ Recipe,
+ SoftwareApplication,
+ Thing,
+ Video,
+ WebPage,
+ WebSite,
+} from './types'
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Core JSON-LD Builder
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Creates a JSON-LD script meta descriptor.
+ *
+ * This is the low-level builder for any Schema.org type.
+ * For common types, prefer the specific builders like `jsonLd.product()`.
+ *
+ * @example
+ * ```ts
+ * import { jsonLd } from '@tanstack/meta/json-ld'
+ *
+ * // Single schema
+ * jsonLd.create({
+ * '@type': 'WebSite',
+ * name: 'My Site',
+ * url: 'https://example.com',
+ * })
+ *
+ * // Multiple schemas with @graph
+ * jsonLd.create([
+ * { '@type': 'WebSite', name: 'My Site' },
+ * { '@type': 'Organization', name: 'My Org' },
+ * ])
+ * ```
+ */
+function create(schema: T | Array): Array {
+ const document = Array.isArray(schema)
+ ? { '@context': 'https://schema.org', '@graph': schema }
+ : { '@context': 'https://schema.org', ...schema }
+
+ return [{ 'script:ld+json': document }]
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Simplified Builders
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Creates a WebSite JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.website({
+ * name: 'My Site',
+ * url: 'https://example.com',
+ * searchUrl: 'https://example.com/search?q={query}',
+ * })
+ * ```
+ */
+function website(config: {
+ name: string
+ url?: string
+ description?: string
+ searchUrl?: string
+}): Array {
+ const schema: WebSite = {
+ '@type': 'WebSite',
+ name: config.name,
+ url: config.url,
+ description: config.description,
+ }
+
+ if (config.searchUrl) {
+ schema.potentialAction = {
+ '@type': 'SearchAction',
+ target: config.searchUrl.includes('{')
+ ? { '@type': 'EntryPoint', urlTemplate: config.searchUrl }
+ : config.searchUrl,
+ 'query-input': 'required name=query',
+ }
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates an Organization JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.organization({
+ * name: 'My Company',
+ * url: 'https://example.com',
+ * logo: 'https://example.com/logo.png',
+ * socials: ['https://twitter.com/company', 'https://linkedin.com/company/...'],
+ * })
+ * ```
+ */
+function organization(config: {
+ name: string
+ url?: string
+ logo?: string
+ description?: string
+ email?: string
+ telephone?: string
+ address?: { street?: string; city?: string; region?: string; postal?: string; country?: string }
+ socials?: Array
+}): Array {
+ const schema: Organization = {
+ '@type': 'Organization',
+ name: config.name,
+ url: config.url,
+ description: config.description,
+ logo: config.logo,
+ email: config.email,
+ telephone: config.telephone,
+ sameAs: config.socials,
+ }
+
+ if (config.address) {
+ schema.address = {
+ '@type': 'PostalAddress',
+ streetAddress: config.address.street,
+ addressLocality: config.address.city,
+ addressRegion: config.address.region,
+ postalCode: config.address.postal,
+ addressCountry: config.address.country,
+ }
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates a Person JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.person({
+ * name: 'John Doe',
+ * url: 'https://johndoe.com',
+ * jobTitle: 'Software Engineer',
+ * })
+ * ```
+ */
+function person(config: {
+ name: string
+ url?: string
+ image?: string
+ jobTitle?: string
+ email?: string
+ socials?: Array
+}): Array {
+ const schema: Person = {
+ '@type': 'Person',
+ name: config.name,
+ url: config.url,
+ image: config.image,
+ jobTitle: config.jobTitle,
+ email: config.email,
+ sameAs: config.socials,
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates an Article JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.article({
+ * headline: 'Article Title',
+ * description: 'Article description',
+ * author: 'John Doe',
+ * datePublished: '2024-01-15',
+ * image: 'https://example.com/image.jpg',
+ * })
+ * ```
+ */
+function article(config: {
+ headline: string
+ description?: string
+ author?: string | { name: string; url?: string }
+ datePublished?: string
+ dateModified?: string
+ image?: string
+ section?: string
+ keywords?: Array
+ type?: 'Article' | 'NewsArticle' | 'BlogPosting' | 'TechArticle'
+}): Array {
+ const authorSchema: Person | undefined = config.author
+ ? typeof config.author === 'string'
+ ? { '@type': 'Person', name: config.author }
+ : { '@type': 'Person', name: config.author.name, url: config.author.url }
+ : undefined
+
+ const schema: Article = {
+ '@type': config.type ?? 'Article',
+ headline: config.headline,
+ description: config.description,
+ author: authorSchema,
+ datePublished: config.datePublished,
+ dateModified: config.dateModified,
+ image: config.image,
+ articleSection: config.section,
+ keywords: config.keywords,
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates a Product JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.product({
+ * name: 'Cool Product',
+ * description: 'A very cool product',
+ * image: 'https://example.com/product.jpg',
+ * price: 99.99,
+ * currency: 'USD',
+ * availability: 'InStock',
+ * brand: 'My Brand',
+ * })
+ * ```
+ */
+function product(config: {
+ name: string
+ description?: string
+ image?: string
+ price?: number
+ currency?: string
+ availability?: 'InStock' | 'OutOfStock' | 'PreOrder' | string
+ brand?: string
+ sku?: string
+ rating?: { value: number; count?: number }
+}): Array {
+ const schema: Product = {
+ '@type': 'Product',
+ name: config.name,
+ description: config.description,
+ image: config.image,
+ sku: config.sku,
+ }
+
+ if (config.brand) {
+ schema.brand = { '@type': 'Brand', name: config.brand }
+ }
+
+ if (config.price !== undefined) {
+ const availability = config.availability
+ ? config.availability.startsWith('https://')
+ ? config.availability
+ : `https://schema.org/${config.availability}`
+ : undefined
+
+ schema.offers = {
+ '@type': 'Offer',
+ price: config.price,
+ priceCurrency: config.currency ?? 'USD',
+ availability,
+ }
+ }
+
+ if (config.rating) {
+ schema.aggregateRating = {
+ '@type': 'AggregateRating',
+ ratingValue: config.rating.value,
+ ratingCount: config.rating.count,
+ }
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates a BreadcrumbList JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.breadcrumbs([
+ * { name: 'Home', url: 'https://example.com' },
+ * { name: 'Products', url: 'https://example.com/products' },
+ * { name: 'Widget', url: 'https://example.com/products/widget' },
+ * ])
+ * ```
+ */
+function breadcrumbs(
+ items: Array<{ name: string; url: string }>,
+): Array {
+ return create({
+ '@type': 'BreadcrumbList',
+ itemListElement: items.map((item, index) => ({
+ '@type': 'ListItem',
+ position: index + 1,
+ name: item.name,
+ item: item.url,
+ })),
+ })
+}
+
+/**
+ * Creates a FAQPage JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.faq([
+ * { question: 'What is...?', answer: 'It is...' },
+ * { question: 'How do I...?', answer: 'You can...' },
+ * ])
+ * ```
+ */
+function faq(
+ items: Array<{ question: string; answer: string }>,
+): Array {
+ const schema: FAQPage = {
+ '@type': 'FAQPage',
+ mainEntity: items.map((item) => ({
+ '@type': 'Question',
+ name: item.question,
+ acceptedAnswer: {
+ '@type': 'Answer',
+ text: item.answer,
+ },
+ })),
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates an Event JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.event({
+ * name: 'Concert',
+ * startDate: '2024-06-15T19:00:00-07:00',
+ * endDate: '2024-06-15T22:00:00-07:00',
+ * location: 'Madison Square Garden, New York',
+ * // or
+ * location: { name: 'Madison Square Garden', address: 'NYC' },
+ * })
+ * ```
+ */
+function event(config: {
+ name: string
+ description?: string
+ startDate?: string
+ endDate?: string
+ location?: string | { name: string; address?: string; url?: string }
+ image?: string
+ organizer?: string
+ url?: string
+ type?: Event['@type']
+}): Array {
+ let locationSchema: Event['location']
+
+ if (config.location) {
+ if (typeof config.location === 'string') {
+ locationSchema = config.location
+ } else if (config.location.url) {
+ locationSchema = {
+ '@type': 'VirtualLocation',
+ url: config.location.url,
+ }
+ } else {
+ locationSchema = {
+ '@type': 'Place',
+ name: config.location.name,
+ address: config.location.address,
+ }
+ }
+ }
+
+ const schema: Event = {
+ '@type': config.type ?? 'Event',
+ name: config.name,
+ description: config.description,
+ startDate: config.startDate,
+ endDate: config.endDate,
+ location: locationSchema,
+ image: config.image,
+ url: config.url,
+ organizer: config.organizer
+ ? { '@type': 'Organization', name: config.organizer }
+ : undefined,
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates a LocalBusiness JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.localBusiness({
+ * name: 'My Restaurant',
+ * type: 'Restaurant',
+ * address: '123 Main St, City, State 12345',
+ * telephone: '+1-555-555-5555',
+ * openingHours: 'Mo-Fr 09:00-17:00',
+ * priceRange: '$$',
+ * })
+ * ```
+ */
+function localBusiness(config: {
+ name: string
+ type?: 'LocalBusiness' | 'Restaurant' | 'Store' | string
+ description?: string
+ url?: string
+ image?: string
+ telephone?: string
+ email?: string
+ address?: string | { street?: string; city?: string; region?: string; postal?: string; country?: string }
+ openingHours?: string | Array
+ priceRange?: string
+ rating?: { value: number; count?: number }
+}): Array {
+ let addressSchema: LocalBusiness['address']
+
+ if (config.address) {
+ if (typeof config.address === 'string') {
+ addressSchema = config.address
+ } else {
+ addressSchema = {
+ '@type': 'PostalAddress',
+ streetAddress: config.address.street,
+ addressLocality: config.address.city,
+ addressRegion: config.address.region,
+ postalCode: config.address.postal,
+ addressCountry: config.address.country,
+ }
+ }
+ }
+
+ const schema: LocalBusiness = {
+ '@type': config.type ?? 'LocalBusiness',
+ name: config.name,
+ description: config.description,
+ url: config.url,
+ image: config.image,
+ telephone: config.telephone,
+ email: config.email,
+ address: addressSchema,
+ openingHours: config.openingHours,
+ priceRange: config.priceRange,
+ }
+
+ if (config.rating) {
+ schema.aggregateRating = {
+ '@type': 'AggregateRating',
+ ratingValue: config.rating.value,
+ ratingCount: config.rating.count,
+ }
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates a SoftwareApplication JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.softwareApp({
+ * name: 'My App',
+ * type: 'MobileApplication',
+ * operatingSystem: 'iOS, Android',
+ * category: 'GameApplication',
+ * price: 0,
+ * rating: { value: 4.5, count: 1000 },
+ * })
+ * ```
+ */
+function softwareApp(config: {
+ name: string
+ type?: 'SoftwareApplication' | 'MobileApplication' | 'WebApplication'
+ description?: string
+ url?: string
+ operatingSystem?: string
+ category?: string
+ price?: number
+ currency?: string
+ rating?: { value: number; count?: number }
+}): Array {
+ const schema: SoftwareApplication = {
+ '@type': config.type ?? 'SoftwareApplication',
+ name: config.name,
+ description: config.description,
+ url: config.url,
+ operatingSystem: config.operatingSystem,
+ applicationCategory: config.category,
+ }
+
+ if (config.price !== undefined) {
+ schema.offers = {
+ '@type': 'Offer',
+ price: config.price,
+ priceCurrency: config.currency ?? 'USD',
+ }
+ }
+
+ if (config.rating) {
+ schema.aggregateRating = {
+ '@type': 'AggregateRating',
+ ratingValue: config.rating.value,
+ ratingCount: config.rating.count,
+ }
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates a VideoObject JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.video({
+ * name: 'Video Title',
+ * description: 'Video description',
+ * thumbnail: 'https://example.com/thumb.jpg',
+ * uploadDate: '2024-01-15',
+ * contentUrl: 'https://example.com/video.mp4',
+ * duration: 'PT5M30S', // 5 minutes 30 seconds
+ * })
+ * ```
+ */
+function video(config: {
+ name: string
+ description?: string
+ thumbnail?: string
+ uploadDate?: string
+ contentUrl?: string
+ embedUrl?: string
+ duration?: string
+}): Array {
+ const schema: Video = {
+ '@type': 'VideoObject',
+ name: config.name,
+ description: config.description,
+ thumbnailUrl: config.thumbnail,
+ uploadDate: config.uploadDate,
+ contentUrl: config.contentUrl,
+ embedUrl: config.embedUrl,
+ duration: config.duration,
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates a Recipe JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.recipe({
+ * name: 'Chocolate Cake',
+ * description: 'Delicious chocolate cake',
+ * author: 'Chef John',
+ * prepTime: 'PT30M',
+ * cookTime: 'PT1H',
+ * servings: '8 servings',
+ * ingredients: ['2 cups flour', '1 cup sugar', '...'],
+ * })
+ * ```
+ */
+function recipe(config: {
+ name: string
+ description?: string
+ author?: string
+ image?: string
+ prepTime?: string
+ cookTime?: string
+ totalTime?: string
+ servings?: string
+ ingredients?: Array
+ instructions?: string | Array
+ category?: string
+ cuisine?: string
+ keywords?: string
+ rating?: { value: number; count?: number }
+}): Array {
+ const schema: Recipe = {
+ '@type': 'Recipe',
+ name: config.name,
+ description: config.description,
+ image: config.image,
+ prepTime: config.prepTime,
+ cookTime: config.cookTime,
+ totalTime: config.totalTime,
+ recipeYield: config.servings,
+ recipeIngredient: config.ingredients,
+ recipeCategory: config.category,
+ recipeCuisine: config.cuisine,
+ keywords: config.keywords,
+ }
+
+ if (config.author) {
+ schema.author = { '@type': 'Person', name: config.author }
+ }
+
+ if (config.instructions) {
+ if (typeof config.instructions === 'string') {
+ schema.recipeInstructions = config.instructions
+ } else {
+ schema.recipeInstructions = config.instructions.map((text) => ({
+ '@type': 'HowToStep',
+ text,
+ }))
+ }
+ }
+
+ if (config.rating) {
+ schema.aggregateRating = {
+ '@type': 'AggregateRating',
+ ratingValue: config.rating.value,
+ ratingCount: config.rating.count,
+ }
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates a Course JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.course({
+ * name: 'Introduction to Programming',
+ * description: 'Learn the basics',
+ * provider: 'Online Academy',
+ * url: 'https://example.com/course',
+ * })
+ * ```
+ */
+function course(config: {
+ name: string
+ description?: string
+ provider?: string
+ url?: string
+ image?: string
+}): Array {
+ const schema: Course = {
+ '@type': 'Course',
+ name: config.name,
+ description: config.description,
+ url: config.url,
+ image: config.image,
+ }
+
+ if (config.provider) {
+ schema.provider = { '@type': 'Organization', name: config.provider }
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates a HowTo JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.howTo({
+ * name: 'How to Make Coffee',
+ * description: 'A simple guide',
+ * totalTime: 'PT5M',
+ * steps: [
+ * 'Boil water',
+ * 'Add coffee grounds',
+ * 'Pour water over grounds',
+ * 'Wait 4 minutes',
+ * ],
+ * })
+ * ```
+ */
+function howTo(config: {
+ name: string
+ description?: string
+ totalTime?: string
+ steps: Array
+}): Array {
+ const schema: HowTo = {
+ '@type': 'HowTo',
+ name: config.name,
+ description: config.description,
+ totalTime: config.totalTime,
+ step: config.steps.map((step) =>
+ typeof step === 'string'
+ ? { '@type': 'HowToStep', text: step }
+ : { '@type': 'HowToStep', name: step.name, text: step.text, image: step.image },
+ ),
+ }
+
+ return create(schema)
+}
+
+/**
+ * Creates a WebPage JSON-LD schema
+ *
+ * @example
+ * ```ts
+ * jsonLd.webpage({
+ * name: 'About Us',
+ * description: 'Learn about our company',
+ * url: 'https://example.com/about',
+ * })
+ * ```
+ */
+function webpage(config: {
+ name: string
+ description?: string
+ url?: string
+ datePublished?: string
+ dateModified?: string
+ inLanguage?: string
+}): Array {
+ const schema: WebPage = {
+ '@type': 'WebPage',
+ name: config.name,
+ description: config.description,
+ url: config.url,
+ datePublished: config.datePublished,
+ dateModified: config.dateModified,
+ inLanguage: config.inLanguage,
+ }
+
+ return create(schema)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Namespace Export
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * JSON-LD builder namespace.
+ *
+ * Provides convenient methods for creating structured data for search engines.
+ *
+ * @example
+ * ```ts
+ * import { jsonLd } from '@tanstack/meta/json-ld'
+ *
+ * // In a route's head function
+ * head: () => ({
+ * meta: [
+ * ...createMeta({ title: 'Product', description: 'Great product' }),
+ * ...jsonLd.product({
+ * name: 'Cool Widget',
+ * price: 99.99,
+ * currency: 'USD',
+ * }),
+ * ],
+ * })
+ * ```
+ */
+export const jsonLd = {
+ /** Create raw JSON-LD from any schema */
+ create,
+ /** WebSite schema */
+ website,
+ /** WebPage schema */
+ webpage,
+ /** Organization schema */
+ organization,
+ /** Person schema */
+ person,
+ /** Article/BlogPosting schema */
+ article,
+ /** Product schema */
+ product,
+ /** BreadcrumbList schema */
+ breadcrumbs,
+ /** FAQPage schema */
+ faq,
+ /** Event schema */
+ event,
+ /** LocalBusiness schema */
+ localBusiness,
+ /** SoftwareApplication schema */
+ softwareApp,
+ /** VideoObject schema */
+ video,
+ /** Recipe schema */
+ recipe,
+ /** Course schema */
+ course,
+ /** HowTo schema */
+ howTo,
+} as const
diff --git a/packages/meta/src/json-ld/index.ts b/packages/meta/src/json-ld/index.ts
new file mode 100644
index 00000000000..c657baa814f
--- /dev/null
+++ b/packages/meta/src/json-ld/index.ts
@@ -0,0 +1,27 @@
+/**
+ * @tanstack/meta/json-ld
+ *
+ * JSON-LD structured data builders for SEO and rich results.
+ *
+ * @example
+ * ```ts
+ * import { jsonLd } from '@tanstack/meta/json-ld'
+ *
+ * // Add structured data to a product page
+ * head: () => ({
+ * meta: [
+ * ...createMeta({ title: 'Product', description: 'Great product' }),
+ * ...jsonLd.product({
+ * name: 'Cool Widget',
+ * price: 99.99,
+ * currency: 'USD',
+ * availability: 'InStock',
+ * }),
+ * ],
+ * })
+ * ```
+ */
+
+export { jsonLd } from './builders'
+export { JsonLd } from './types'
+export type * from './types'
diff --git a/packages/meta/src/json-ld/types.ts b/packages/meta/src/json-ld/types.ts
new file mode 100644
index 00000000000..b92963fb020
--- /dev/null
+++ b/packages/meta/src/json-ld/types.ts
@@ -0,0 +1,370 @@
+/**
+ * JSON-LD Schema Types
+ *
+ * These types represent common Schema.org types used for structured data.
+ * Import types using the JsonLd namespace:
+ *
+ * @example
+ * ```ts
+ * import type { JsonLd } from '@tanstack/meta/json-ld'
+ *
+ * const product: JsonLd.Product = { ... }
+ * ```
+ */
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Base Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+/** Base properties shared by all Schema.org types */
+export interface Thing {
+ '@type': string
+ '@id'?: string
+ name?: string
+ description?: string
+ url?: string
+ image?: string | Image | Array
+ sameAs?: string | Array
+}
+
+export interface Image extends Thing {
+ '@type': 'ImageObject'
+ contentUrl?: string
+ width?: number | string
+ height?: number | string
+ caption?: string
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Organization Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface Organization extends Thing {
+ '@type': 'Organization'
+ logo?: string | Image
+ email?: string
+ telephone?: string
+ address?: PostalAddress | string
+ contactPoint?: ContactPoint | Array
+}
+
+export interface ContactPoint extends Thing {
+ '@type': 'ContactPoint'
+ telephone?: string
+ contactType?: string
+ email?: string
+ areaServed?: string | Array
+}
+
+export interface PostalAddress extends Thing {
+ '@type': 'PostalAddress'
+ streetAddress?: string
+ addressLocality?: string
+ addressRegion?: string
+ postalCode?: string
+ addressCountry?: string
+}
+
+export interface Person extends Thing {
+ '@type': 'Person'
+ givenName?: string
+ familyName?: string
+ email?: string
+ telephone?: string
+ jobTitle?: string
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Website & Page Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface WebSite extends Thing {
+ '@type': 'WebSite'
+ publisher?: Organization | Person
+ potentialAction?: SearchAction | Array
+}
+
+export interface SearchAction {
+ '@type': 'SearchAction'
+ target: string | { '@type': 'EntryPoint'; urlTemplate: string }
+ 'query-input'?: string
+}
+
+export interface WebPage extends Thing {
+ '@type': 'WebPage'
+ breadcrumb?: BreadcrumbList
+ datePublished?: string
+ dateModified?: string
+ author?: Person | Organization
+ publisher?: Organization
+ inLanguage?: string
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Content Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface Article extends Thing {
+ '@type': 'Article' | 'NewsArticle' | 'BlogPosting' | 'TechArticle'
+ headline?: string
+ datePublished?: string
+ dateModified?: string
+ author?: Person | Organization | Array
+ publisher?: Organization
+ articleSection?: string
+ wordCount?: number
+ keywords?: string | Array
+ thumbnailUrl?: string
+}
+
+export interface BreadcrumbList {
+ '@type': 'BreadcrumbList'
+ itemListElement: Array
+}
+
+export interface ListItem {
+ '@type': 'ListItem'
+ position: number
+ name?: string
+ item?: string | Thing
+}
+
+export interface FAQPage extends Thing {
+ '@type': 'FAQPage'
+ mainEntity: Array
+}
+
+export interface Question extends Thing {
+ '@type': 'Question'
+ acceptedAnswer?: Answer
+}
+
+export interface Answer extends Thing {
+ '@type': 'Answer'
+ text?: string
+}
+
+export interface HowTo extends Thing {
+ '@type': 'HowTo'
+ step: Array
+ totalTime?: string
+ estimatedCost?: string
+}
+
+export interface HowToStep {
+ '@type': 'HowToStep'
+ name?: string
+ text?: string
+ url?: string
+ image?: string | Image
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Product & Commerce Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface Product extends Thing {
+ '@type': 'Product'
+ brand?: Organization | { '@type': 'Brand'; name: string }
+ sku?: string
+ gtin?: string
+ mpn?: string
+ offers?: Offer | Array
+ aggregateRating?: AggregateRating
+ review?: Review | Array
+}
+
+export interface Offer extends Thing {
+ '@type': 'Offer'
+ price?: number | string
+ priceCurrency?: string
+ priceValidUntil?: string
+ availability?:
+ | 'https://schema.org/InStock'
+ | 'https://schema.org/OutOfStock'
+ | 'https://schema.org/PreOrder'
+ | string
+ itemCondition?:
+ | 'https://schema.org/NewCondition'
+ | 'https://schema.org/UsedCondition'
+ | string
+ seller?: Organization | Person
+}
+
+export interface AggregateRating {
+ '@type': 'AggregateRating'
+ ratingValue: number | string
+ ratingCount?: number
+ reviewCount?: number
+ bestRating?: number | string
+ worstRating?: number | string
+}
+
+export interface Review extends Thing {
+ '@type': 'Review'
+ author?: Person | Organization
+ datePublished?: string
+ reviewBody?: string
+ reviewRating?: {
+ '@type': 'Rating'
+ ratingValue: number | string
+ bestRating?: number | string
+ worstRating?: number | string
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Event Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface Event extends Thing {
+ '@type': 'Event' | 'MusicEvent' | 'BusinessEvent' | string
+ startDate?: string
+ endDate?: string
+ location?: Place | VirtualLocation | string
+ organizer?: Organization | Person
+ performer?: Person | Organization | Array
+ offers?: Offer | Array
+ eventStatus?:
+ | 'https://schema.org/EventScheduled'
+ | 'https://schema.org/EventCancelled'
+ | 'https://schema.org/EventPostponed'
+ | string
+ eventAttendanceMode?:
+ | 'https://schema.org/OfflineEventAttendanceMode'
+ | 'https://schema.org/OnlineEventAttendanceMode'
+ | 'https://schema.org/MixedEventAttendanceMode'
+ | string
+}
+
+export interface Place extends Thing {
+ '@type': 'Place'
+ address?: PostalAddress | string
+ geo?: { '@type': 'GeoCoordinates'; latitude?: number; longitude?: number }
+}
+
+export interface VirtualLocation {
+ '@type': 'VirtualLocation'
+ url?: string
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Business Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface LocalBusiness extends Omit {
+ '@type': 'LocalBusiness' | 'Restaurant' | 'Store' | string
+ address?: PostalAddress | string
+ geo?: { '@type': 'GeoCoordinates'; latitude?: number; longitude?: number }
+ openingHours?: string | Array
+ priceRange?: string
+ aggregateRating?: AggregateRating
+ review?: Review | Array
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Media Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface Video extends Thing {
+ '@type': 'VideoObject'
+ contentUrl?: string
+ embedUrl?: string
+ uploadDate?: string
+ duration?: string
+ thumbnailUrl?: string | Array
+ transcript?: string
+}
+
+export interface SoftwareApplication extends Thing {
+ '@type': 'SoftwareApplication' | 'MobileApplication' | 'WebApplication'
+ applicationCategory?: string
+ operatingSystem?: string
+ offers?: Offer | Array
+ aggregateRating?: AggregateRating
+ downloadUrl?: string
+ softwareVersion?: string
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Education Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface Course extends Thing {
+ '@type': 'Course'
+ provider?: Organization
+ courseCode?: string
+ aggregateRating?: AggregateRating
+ offers?: Offer | Array
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Recipe Type
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface Recipe extends Thing {
+ '@type': 'Recipe'
+ author?: Person | Organization
+ datePublished?: string
+ prepTime?: string
+ cookTime?: string
+ totalTime?: string
+ recipeYield?: string
+ recipeIngredient?: Array
+ recipeInstructions?: Array | string
+ recipeCategory?: string
+ recipeCuisine?: string
+ aggregateRating?: AggregateRating
+ keywords?: string
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Namespace Export
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * JSON-LD type namespace
+ *
+ * @example
+ * ```ts
+ * import type { JsonLd } from '@tanstack/meta/json-ld'
+ *
+ * const product: JsonLd.Product = {
+ * '@type': 'Product',
+ * name: 'Cool Product',
+ * }
+ * ```
+ */
+export namespace JsonLd {
+ export type {
+ Thing,
+ Image,
+ Organization,
+ ContactPoint,
+ PostalAddress,
+ Person,
+ WebSite,
+ SearchAction,
+ WebPage,
+ Article,
+ BreadcrumbList,
+ ListItem,
+ FAQPage,
+ Question,
+ Answer,
+ HowTo,
+ HowToStep,
+ Product,
+ Offer,
+ AggregateRating,
+ Review,
+ Event,
+ Place,
+ VirtualLocation,
+ LocalBusiness,
+ Video,
+ SoftwareApplication,
+ Course,
+ Recipe,
+ }
+}
diff --git a/packages/meta/src/merge.ts b/packages/meta/src/merge.ts
new file mode 100644
index 00000000000..17a01f504d8
--- /dev/null
+++ b/packages/meta/src/merge.ts
@@ -0,0 +1,177 @@
+import type { MetaDescriptor } from './types'
+
+/**
+ * Merge strategy for handling duplicate meta tags
+ */
+export type MergeStrategy = 'last-wins' | 'first-wins' | 'append'
+
+/**
+ * Options for mergeMeta
+ */
+export interface MergeOptions {
+ /**
+ * How to handle duplicates
+ * - 'last-wins': Later values override earlier ones (default)
+ * - 'first-wins': Keep first occurrence
+ * - 'append': Keep all values, no deduplication
+ */
+ strategy?: MergeStrategy
+}
+
+/**
+ * Merges multiple meta arrays with intelligent deduplication.
+ *
+ * By default, later values override earlier ones for the same key.
+ * Keys are determined by:
+ * - title: 'title'
+ * - name: 'name:${name}'
+ * - property: 'property:${property}'
+ * - canonical link: 'link:canonical'
+ * - JSON-LD: 'jsonld:${@type}'
+ *
+ * @example
+ * ```ts
+ * import { createMeta, mergeMeta } from '@tanstack/meta'
+ * import { jsonLd } from '@tanstack/meta/json-ld'
+ *
+ * // Merge parent route meta with child route meta
+ * head: ({ matches }) => {
+ * const parentMeta = matches[0]?.meta ?? []
+ * return {
+ * meta: mergeMeta(
+ * parentMeta,
+ * createMeta({ title: 'Child Page', description: 'Child desc' }),
+ * ),
+ * }
+ * }
+ * ```
+ */
+export function mergeMeta(
+ ...sources: Array | undefined | null>
+): Array {
+ return mergeMetaWith({}, ...sources)
+}
+
+/**
+ * Merges meta arrays with custom options
+ *
+ * @example
+ * ```ts
+ * // Keep first occurrence instead of last
+ * mergeMetaWith({ strategy: 'first-wins' }, baseMeta, overrideMeta)
+ *
+ * // Keep all values without deduplication
+ * mergeMetaWith({ strategy: 'append' }, meta1, meta2)
+ * ```
+ */
+export function mergeMetaWith(
+ options: MergeOptions,
+ ...sources: Array | undefined | null>
+): Array {
+ const { strategy = 'last-wins' } = options
+
+ if (strategy === 'append') {
+ return sources.filter(Boolean).flat() as Array
+ }
+
+ const metaByKey = new Map()
+ const orderedKeys: Array = []
+
+ for (const source of sources) {
+ if (!source) continue
+
+ for (const descriptor of source) {
+ const key = getMetaKey(descriptor)
+
+ if (strategy === 'first-wins' && metaByKey.has(key)) {
+ continue
+ }
+
+ if (!metaByKey.has(key)) {
+ orderedKeys.push(key)
+ }
+
+ metaByKey.set(key, descriptor)
+ }
+ }
+
+ return orderedKeys.map((key) => metaByKey.get(key)!)
+}
+
+/**
+ * Gets a unique key for a meta descriptor for deduplication
+ */
+function getMetaKey(descriptor: MetaDescriptor): string {
+ if ('charSet' in descriptor) return 'charset'
+ if ('title' in descriptor) return 'title'
+ if ('name' in descriptor && 'content' in descriptor)
+ return `name:${(descriptor as any).name}`
+ if ('property' in descriptor && 'content' in descriptor)
+ return `property:${(descriptor as any).property}`
+ if ('httpEquiv' in descriptor) return `http-equiv:${(descriptor as any).httpEquiv}`
+ if ('script:ld+json' in descriptor) {
+ const ldJson = (descriptor as any)['script:ld+json']
+ if (ldJson['@id']) return `jsonld:${ldJson['@id']}`
+ if (ldJson['@type']) return `jsonld:${ldJson['@type']}`
+ if (ldJson['@graph']) return 'jsonld:graph'
+ return `jsonld:${JSON.stringify(ldJson)}`
+ }
+ if ('tagName' in descriptor) {
+ const tagName = (descriptor as any).tagName
+ if (tagName === 'link' && (descriptor as any).rel === 'canonical')
+ return 'link:canonical'
+ if (tagName === 'link' && (descriptor as any).rel === 'alternate') {
+ const hreflang = (descriptor as any).hreflang || ''
+ return `link:alternate:${hreflang}`
+ }
+ return `${tagName}:${JSON.stringify(descriptor)}`
+ }
+
+ return JSON.stringify(descriptor)
+}
+
+/**
+ * Removes specific meta tags from an array by their keys
+ *
+ * @example
+ * ```ts
+ * // Remove og:image from parent meta
+ * const filtered = excludeMeta(parentMeta, ['og:image', 'twitter:image'])
+ * ```
+ */
+export function excludeMeta(
+ meta: Array,
+ keys: Array,
+): Array {
+ const keySet = new Set(keys.map(normalizeKey))
+ return meta.filter((m) => !keySet.has(getMetaKey(m)))
+}
+
+/**
+ * Picks specific meta tags from an array by their keys
+ *
+ * @example
+ * ```ts
+ * // Keep only title and description
+ * const picked = pickMeta(parentMeta, ['title', 'description'])
+ * ```
+ */
+export function pickMeta(
+ meta: Array,
+ keys: Array,
+): Array {
+ const keySet = new Set(keys.map(normalizeKey))
+ return meta.filter((m) => keySet.has(getMetaKey(m)))
+}
+
+/**
+ * Normalizes a user-provided key to match our internal key format
+ */
+function normalizeKey(key: string): string {
+ if (key === 'title' || key === 'charset' || key === 'description')
+ return key === 'description' ? 'name:description' : key
+ if (key.startsWith('og:')) return `property:${key}`
+ if (key.startsWith('twitter:')) return `name:${key}`
+ if (key.startsWith('article:')) return `property:${key}`
+ return key
+}
diff --git a/packages/meta/src/types.ts b/packages/meta/src/types.ts
new file mode 100644
index 00000000000..7c6cf505d16
--- /dev/null
+++ b/packages/meta/src/types.ts
@@ -0,0 +1,100 @@
+/**
+ * Meta descriptor type compatible with TanStack Router's head function.
+ * Each descriptor represents a single meta tag, link, or script element.
+ */
+export type MetaDescriptor =
+ | { charSet: 'utf-8' }
+ | { title: string }
+ | { name: string; content: string }
+ | { property: string; content: string }
+ | { httpEquiv: string; content: string }
+ | { 'script:ld+json': JsonLdObject }
+ | { tagName: 'meta' | 'link'; [name: string]: string }
+ | Record
+
+type JsonLdObject = { [Key in string]: JsonLdValue } & {
+ [Key in string]?: JsonLdValue | undefined
+}
+type JsonLdArray = Array | ReadonlyArray
+type JsonLdPrimitive = string | number | boolean | null
+type JsonLdValue = JsonLdPrimitive | JsonLdObject | JsonLdArray
+
+/**
+ * Image configuration for Open Graph and Twitter cards
+ */
+export interface MetaImage {
+ /** Image URL */
+ url: string
+ /** Image width in pixels */
+ width?: number
+ /** Image height in pixels */
+ height?: number
+ /** Alt text for accessibility */
+ alt?: string
+}
+
+/**
+ * Robot directives for search engine crawlers
+ */
+export interface RobotsConfig {
+ /** Allow indexing (default: true) */
+ index?: boolean
+ /** Allow following links (default: true) */
+ follow?: boolean
+ /** Prevent caching */
+ noarchive?: boolean
+ /** Prevent snippets */
+ nosnippet?: boolean
+ /** Maximum snippet length */
+ maxSnippet?: number
+ /** Maximum image preview size */
+ maxImagePreview?: 'none' | 'standard' | 'large'
+}
+
+/**
+ * Open Graph configuration for social sharing
+ */
+export interface OpenGraphConfig {
+ /** Content title (defaults to main title) */
+ title?: string
+ /** Content description (defaults to main description) */
+ description?: string
+ /** Content type */
+ type?: 'website' | 'article' | 'product' | 'profile' | string
+ /** Canonical URL */
+ url?: string
+ /** Site name */
+ siteName?: string
+ /** Locale (e.g., 'en_US') */
+ locale?: string
+ /** Images */
+ images?: Array
+ /** Article-specific properties */
+ article?: {
+ publishedTime?: string
+ modifiedTime?: string
+ authors?: Array
+ section?: string
+ tags?: Array
+ }
+}
+
+/**
+ * Twitter Card configuration
+ */
+export interface TwitterConfig {
+ /** Card type */
+ card?: 'summary' | 'summary_large_image' | 'app' | 'player'
+ /** @username of website */
+ site?: string
+ /** @username of content creator */
+ creator?: string
+ /** Title (defaults to main title) */
+ title?: string
+ /** Description (defaults to main description) */
+ description?: string
+ /** Image URL */
+ image?: string
+ /** Image alt text */
+ imageAlt?: string
+}
diff --git a/packages/meta/tests/builders.test.ts b/packages/meta/tests/builders.test.ts
new file mode 100644
index 00000000000..4454a99155a
--- /dev/null
+++ b/packages/meta/tests/builders.test.ts
@@ -0,0 +1,359 @@
+import { describe, expect, it } from 'vitest'
+import {
+ meta,
+ title,
+ description,
+ charset,
+ viewport,
+ robots,
+ canonical,
+ alternate,
+ openGraph,
+ twitter,
+ themeColor,
+ verification,
+} from '../src/builders'
+
+describe('Core Builders', () => {
+ describe('title', () => {
+ it('should create a title descriptor', () => {
+ const result = title('My Page')
+ expect(result).toEqual([{ title: 'My Page' }])
+ })
+
+ it('should apply template to title', () => {
+ const result = title('My Page', '%s | My Site')
+ expect(result).toEqual([{ title: 'My Page | My Site' }])
+ })
+ })
+
+ describe('description', () => {
+ it('should create a description meta descriptor', () => {
+ const result = description('This is my page description')
+ expect(result).toEqual([
+ { name: 'description', content: 'This is my page description' },
+ ])
+ })
+ })
+
+ describe('charset', () => {
+ it('should create a charset descriptor with utf-8', () => {
+ const result = charset()
+ expect(result).toEqual([{ charSet: 'utf-8' }])
+ })
+ })
+
+ describe('viewport', () => {
+ it('should create a viewport descriptor with default value', () => {
+ const result = viewport()
+ expect(result).toEqual([
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ ])
+ })
+
+ it('should create a viewport descriptor with custom value', () => {
+ const result = viewport(
+ 'width=device-width, initial-scale=1, maximum-scale=5',
+ )
+ expect(result).toEqual([
+ {
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1, maximum-scale=5',
+ },
+ ])
+ })
+ })
+})
+
+describe('SEO Builders', () => {
+ describe('robots', () => {
+ it('should return empty array for empty config', () => {
+ const result = robots({})
+ expect(result).toEqual([])
+ })
+
+ it('should create index directive', () => {
+ const result = robots({ index: true })
+ expect(result).toEqual([{ name: 'robots', content: 'index' }])
+ })
+
+ it('should create noindex directive', () => {
+ const result = robots({ index: false })
+ expect(result).toEqual([{ name: 'robots', content: 'noindex' }])
+ })
+
+ it('should create follow directive', () => {
+ const result = robots({ follow: true })
+ expect(result).toEqual([{ name: 'robots', content: 'follow' }])
+ })
+
+ it('should create nofollow directive', () => {
+ const result = robots({ follow: false })
+ expect(result).toEqual([{ name: 'robots', content: 'nofollow' }])
+ })
+
+ it('should combine multiple directives', () => {
+ const result = robots({
+ index: true,
+ follow: true,
+ noarchive: true,
+ maxSnippet: 160,
+ })
+ expect(result).toEqual([
+ {
+ name: 'robots',
+ content: 'index, follow, noarchive, max-snippet:160',
+ },
+ ])
+ })
+
+ it('should include maxImagePreview', () => {
+ const result = robots({
+ maxImagePreview: 'large',
+ })
+ expect(result).toEqual([
+ {
+ name: 'robots',
+ content: 'max-image-preview:large',
+ },
+ ])
+ })
+ })
+
+ describe('canonical', () => {
+ it('should create a canonical link descriptor', () => {
+ const result = canonical('https://example.com/page')
+ expect(result).toEqual([
+ { tagName: 'link', rel: 'canonical', href: 'https://example.com/page' },
+ ])
+ })
+ })
+
+ describe('alternate', () => {
+ it('should create alternate link descriptors', () => {
+ const result = alternate([
+ { lang: 'en', href: 'https://example.com/en/page' },
+ { lang: 'es', href: 'https://example.com/es/page' },
+ { lang: 'x-default', href: 'https://example.com/page' },
+ ])
+ expect(result).toEqual([
+ {
+ tagName: 'link',
+ rel: 'alternate',
+ hreflang: 'en',
+ href: 'https://example.com/en/page',
+ },
+ {
+ tagName: 'link',
+ rel: 'alternate',
+ hreflang: 'es',
+ href: 'https://example.com/es/page',
+ },
+ {
+ tagName: 'link',
+ rel: 'alternate',
+ hreflang: 'x-default',
+ href: 'https://example.com/page',
+ },
+ ])
+ })
+ })
+})
+
+describe('Open Graph Builder', () => {
+ it('should create basic Open Graph descriptors', () => {
+ const result = openGraph({
+ title: 'My Page',
+ description: 'Page description',
+ type: 'website',
+ })
+ expect(result).toEqual([
+ { property: 'og:title', content: 'My Page' },
+ { property: 'og:description', content: 'Page description' },
+ { property: 'og:type', content: 'website' },
+ ])
+ })
+
+ it('should handle string images', () => {
+ const result = openGraph({
+ image: 'https://example.com/image.jpg',
+ })
+ expect(result).toEqual([
+ { property: 'og:image', content: 'https://example.com/image.jpg' },
+ ])
+ })
+
+ it('should handle image objects with dimensions', () => {
+ const result = openGraph({
+ image: {
+ url: 'https://example.com/og.jpg',
+ width: 1200,
+ height: 630,
+ alt: 'OG Image',
+ },
+ })
+ expect(result).toEqual([
+ { property: 'og:image', content: 'https://example.com/og.jpg' },
+ { property: 'og:image:width', content: '1200' },
+ { property: 'og:image:height', content: '630' },
+ { property: 'og:image:alt', content: 'OG Image' },
+ ])
+ })
+
+ it('should handle images array', () => {
+ const result = openGraph({
+ images: [
+ 'https://example.com/image1.jpg',
+ { url: 'https://example.com/image2.jpg', width: 800 },
+ ],
+ })
+ expect(result).toContainEqual({
+ property: 'og:image',
+ content: 'https://example.com/image1.jpg',
+ })
+ expect(result).toContainEqual({
+ property: 'og:image',
+ content: 'https://example.com/image2.jpg',
+ })
+ expect(result).toContainEqual({
+ property: 'og:image:width',
+ content: '800',
+ })
+ })
+
+ it('should include optional properties', () => {
+ const result = openGraph({
+ url: 'https://example.com',
+ siteName: 'My Site',
+ locale: 'en_US',
+ })
+ expect(result).toContainEqual({
+ property: 'og:url',
+ content: 'https://example.com',
+ })
+ expect(result).toContainEqual({
+ property: 'og:site_name',
+ content: 'My Site',
+ })
+ expect(result).toContainEqual({
+ property: 'og:locale',
+ content: 'en_US',
+ })
+ })
+})
+
+describe('Twitter Builder', () => {
+ it('should create Twitter Card descriptors', () => {
+ const result = twitter({
+ card: 'summary_large_image',
+ title: 'My Page',
+ description: 'Page description',
+ image: 'https://example.com/twitter.jpg',
+ site: '@mysite',
+ creator: '@author',
+ })
+ expect(result).toEqual([
+ { name: 'twitter:card', content: 'summary_large_image' },
+ { name: 'twitter:title', content: 'My Page' },
+ { name: 'twitter:description', content: 'Page description' },
+ { name: 'twitter:image', content: 'https://example.com/twitter.jpg' },
+ { name: 'twitter:site', content: '@mysite' },
+ { name: 'twitter:creator', content: '@author' },
+ ])
+ })
+
+ it('should include image alt when provided', () => {
+ const result = twitter({
+ image: 'https://example.com/image.jpg',
+ imageAlt: 'Description of image',
+ })
+ expect(result).toEqual([
+ { name: 'twitter:image', content: 'https://example.com/image.jpg' },
+ { name: 'twitter:image:alt', content: 'Description of image' },
+ ])
+ })
+
+ it('should only include provided properties', () => {
+ const result = twitter({
+ card: 'summary',
+ })
+ expect(result).toEqual([{ name: 'twitter:card', content: 'summary' }])
+ })
+})
+
+describe('Utility Builders', () => {
+ describe('themeColor', () => {
+ it('should create a single theme-color meta tag', () => {
+ const result = themeColor('#ffffff')
+ expect(result).toEqual([{ name: 'theme-color', content: '#ffffff' }])
+ })
+
+ it('should create theme-color meta tags for light and dark modes', () => {
+ const result = themeColor({ light: '#ffffff', dark: '#000000' })
+ expect(result).toEqual([
+ {
+ name: 'theme-color',
+ content: '#ffffff',
+ media: '(prefers-color-scheme: light)',
+ },
+ {
+ name: 'theme-color',
+ content: '#000000',
+ media: '(prefers-color-scheme: dark)',
+ },
+ ])
+ })
+ })
+
+ describe('verification', () => {
+ it('should create verification meta tags', () => {
+ const result = verification({
+ google: 'google-code',
+ bing: 'bing-code',
+ yandex: 'yandex-code',
+ pinterest: 'pinterest-code',
+ })
+ expect(result).toEqual([
+ { name: 'google-site-verification', content: 'google-code' },
+ { name: 'msvalidate.01', content: 'bing-code' },
+ { name: 'yandex-verification', content: 'yandex-code' },
+ { name: 'p:domain_verify', content: 'pinterest-code' },
+ ])
+ })
+
+ it('should only include provided verification codes', () => {
+ const result = verification({ google: 'google-code' })
+ expect(result).toEqual([
+ { name: 'google-site-verification', content: 'google-code' },
+ ])
+ })
+ })
+})
+
+describe('meta namespace', () => {
+ it('should export all builders through namespace', () => {
+ expect(meta.title).toBe(title)
+ expect(meta.description).toBe(description)
+ expect(meta.charset).toBe(charset)
+ expect(meta.viewport).toBe(viewport)
+ expect(meta.robots).toBe(robots)
+ expect(meta.canonical).toBe(canonical)
+ expect(meta.alternate).toBe(alternate)
+ expect(meta.openGraph).toBe(openGraph)
+ expect(meta.twitter).toBe(twitter)
+ expect(meta.themeColor).toBe(themeColor)
+ expect(meta.verification).toBe(verification)
+ })
+
+ it('should allow composing via namespace', () => {
+ const result = [
+ ...meta.charset(),
+ ...meta.viewport(),
+ ...meta.title('My Page'),
+ ...meta.description('Description'),
+ ]
+ expect(result).toHaveLength(4)
+ expect(result).toContainEqual({ charSet: 'utf-8' })
+ expect(result).toContainEqual({ title: 'My Page' })
+ })
+})
diff --git a/packages/meta/tests/createMeta.test.ts b/packages/meta/tests/createMeta.test.ts
new file mode 100644
index 00000000000..5291c4fdac8
--- /dev/null
+++ b/packages/meta/tests/createMeta.test.ts
@@ -0,0 +1,439 @@
+import { describe, expect, it } from 'vitest'
+import { createMeta } from '../src/createMeta'
+
+describe('createMeta', () => {
+ describe('basic usage', () => {
+ it('should create essential meta tags with minimal config', () => {
+ const result = createMeta({
+ title: 'My Page',
+ description: 'A great page',
+ })
+
+ // Should include charset
+ expect(result).toContainEqual({ charSet: 'utf-8' })
+
+ // Should include viewport
+ expect(result).toContainEqual({
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1',
+ })
+
+ // Should include title
+ expect(result).toContainEqual({ title: 'My Page' })
+
+ // Should include description
+ expect(result).toContainEqual({
+ name: 'description',
+ content: 'A great page',
+ })
+
+ // Should include Open Graph basics
+ expect(result).toContainEqual({
+ property: 'og:title',
+ content: 'My Page',
+ })
+ expect(result).toContainEqual({
+ property: 'og:description',
+ content: 'A great page',
+ })
+ expect(result).toContainEqual({
+ property: 'og:type',
+ content: 'website',
+ })
+
+ // Should include Twitter Card
+ expect(result).toContainEqual({
+ name: 'twitter:card',
+ content: 'summary',
+ })
+ expect(result).toContainEqual({
+ name: 'twitter:title',
+ content: 'My Page',
+ })
+ expect(result).toContainEqual({
+ name: 'twitter:description',
+ content: 'A great page',
+ })
+ })
+
+ it('should create full meta tags with url and image', () => {
+ const result = createMeta({
+ title: 'My Page',
+ description: 'A great page',
+ url: 'https://example.com/page',
+ image: 'https://example.com/og.jpg',
+ })
+
+ // Should include canonical
+ expect(result).toContainEqual({
+ tagName: 'link',
+ rel: 'canonical',
+ href: 'https://example.com/page',
+ })
+
+ // Should include OG image
+ expect(result).toContainEqual({
+ property: 'og:image',
+ content: 'https://example.com/og.jpg',
+ })
+
+ // Should include OG url
+ expect(result).toContainEqual({
+ property: 'og:url',
+ content: 'https://example.com/page',
+ })
+
+ // Should use summary_large_image when image is present
+ expect(result).toContainEqual({
+ name: 'twitter:card',
+ content: 'summary_large_image',
+ })
+
+ // Should include twitter image
+ expect(result).toContainEqual({
+ name: 'twitter:image',
+ content: 'https://example.com/og.jpg',
+ })
+ })
+ })
+
+ describe('title template', () => {
+ it('should apply title template', () => {
+ const result = createMeta({
+ title: 'About',
+ description: 'About us',
+ titleTemplate: '%s | My Site',
+ })
+
+ expect(result).toContainEqual({ title: 'About | My Site' })
+ })
+ })
+
+ describe('image with dimensions', () => {
+ it('should include image dimensions when provided', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Description',
+ image: {
+ url: 'https://example.com/og.jpg',
+ width: 1200,
+ height: 630,
+ alt: 'Alt text',
+ },
+ })
+
+ expect(result).toContainEqual({
+ property: 'og:image',
+ content: 'https://example.com/og.jpg',
+ })
+ expect(result).toContainEqual({
+ property: 'og:image:width',
+ content: '1200',
+ })
+ expect(result).toContainEqual({
+ property: 'og:image:height',
+ content: '630',
+ })
+ expect(result).toContainEqual({
+ property: 'og:image:alt',
+ content: 'Alt text',
+ })
+ expect(result).toContainEqual({
+ name: 'twitter:image:alt',
+ content: 'Alt text',
+ })
+ })
+ })
+
+ describe('optional properties', () => {
+ it('should include siteName when provided', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ siteName: 'My Site',
+ })
+
+ expect(result).toContainEqual({
+ property: 'og:site_name',
+ content: 'My Site',
+ })
+ })
+
+ it('should include locale when provided', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ locale: 'en_US',
+ })
+
+ expect(result).toContainEqual({
+ property: 'og:locale',
+ content: 'en_US',
+ })
+ })
+
+ it('should include Twitter site/creator handles', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ twitterSite: '@mysite',
+ twitterCreator: '@author',
+ })
+
+ expect(result).toContainEqual({
+ name: 'twitter:site',
+ content: '@mysite',
+ })
+ expect(result).toContainEqual({
+ name: 'twitter:creator',
+ content: '@author',
+ })
+ })
+ })
+
+ describe('robots', () => {
+ it('should include robots directive when provided', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ robots: { index: false, follow: true, noarchive: true },
+ })
+
+ expect(result).toContainEqual({
+ name: 'robots',
+ content: 'noindex, follow, noarchive',
+ })
+ })
+
+ it('should handle maxSnippet and maxImagePreview', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ robots: { maxSnippet: 160, maxImagePreview: 'large' },
+ })
+
+ expect(result).toContainEqual({
+ name: 'robots',
+ content: 'max-snippet:160, max-image-preview:large',
+ })
+ })
+ })
+
+ describe('theme color', () => {
+ it('should include single theme color', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ themeColor: '#ffffff',
+ })
+
+ expect(result).toContainEqual({
+ name: 'theme-color',
+ content: '#ffffff',
+ })
+ })
+
+ it('should include light/dark theme colors', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ themeColor: { light: '#ffffff', dark: '#000000' },
+ })
+
+ expect(result).toContainEqual({
+ name: 'theme-color',
+ content: '#ffffff',
+ media: '(prefers-color-scheme: light)',
+ })
+ expect(result).toContainEqual({
+ name: 'theme-color',
+ content: '#000000',
+ media: '(prefers-color-scheme: dark)',
+ })
+ })
+ })
+
+ describe('type', () => {
+ it('should default to website type', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ })
+
+ expect(result).toContainEqual({
+ property: 'og:type',
+ content: 'website',
+ })
+ })
+
+ it('should use custom type when provided', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ type: 'article',
+ })
+
+ expect(result).toContainEqual({
+ property: 'og:type',
+ content: 'article',
+ })
+ })
+ })
+
+ describe('Open Graph overrides', () => {
+ it('should override inferred values with openGraph config', () => {
+ const result = createMeta({
+ title: 'Page Title',
+ description: 'Page Desc',
+ openGraph: {
+ title: 'OG Title',
+ description: 'OG Desc',
+ },
+ })
+
+ expect(result).toContainEqual({
+ property: 'og:title',
+ content: 'OG Title',
+ })
+ expect(result).toContainEqual({
+ property: 'og:description',
+ content: 'OG Desc',
+ })
+ })
+
+ it('should include article properties', () => {
+ const result = createMeta({
+ title: 'Article',
+ description: 'Desc',
+ type: 'article',
+ openGraph: {
+ article: {
+ publishedTime: '2024-01-15T08:00:00+00:00',
+ modifiedTime: '2024-01-16T10:00:00+00:00',
+ section: 'Technology',
+ authors: ['John Doe'],
+ tags: ['tech', 'news'],
+ },
+ },
+ })
+
+ expect(result).toContainEqual({
+ property: 'article:published_time',
+ content: '2024-01-15T08:00:00+00:00',
+ })
+ expect(result).toContainEqual({
+ property: 'article:modified_time',
+ content: '2024-01-16T10:00:00+00:00',
+ })
+ expect(result).toContainEqual({
+ property: 'article:section',
+ content: 'Technology',
+ })
+ expect(result).toContainEqual({
+ property: 'article:author',
+ content: 'John Doe',
+ })
+ expect(result).toContainEqual({
+ property: 'article:tag',
+ content: 'tech',
+ })
+ })
+ })
+
+ describe('Twitter overrides', () => {
+ it('should override inferred values with twitter config', () => {
+ const result = createMeta({
+ title: 'Page Title',
+ description: 'Page Desc',
+ twitter: {
+ title: 'Twitter Title',
+ card: 'app',
+ },
+ })
+
+ expect(result).toContainEqual({
+ name: 'twitter:title',
+ content: 'Twitter Title',
+ })
+ expect(result).toContainEqual({
+ name: 'twitter:card',
+ content: 'app',
+ })
+ })
+ })
+
+ describe('control flags', () => {
+ it('should omit charset when charset=false', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ charset: false,
+ })
+
+ expect(result).not.toContainEqual({ charSet: 'utf-8' })
+ })
+
+ it('should omit viewport when viewport=false', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ viewport: false,
+ })
+
+ expect(result).not.toContainEqual({
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1',
+ })
+ })
+
+ it('should use custom viewport when string provided', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ viewport: 'width=device-width, initial-scale=1, maximum-scale=5',
+ })
+
+ expect(result).toContainEqual({
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1, maximum-scale=5',
+ })
+ })
+
+ it('should omit canonical when canonical=false', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ url: 'https://example.com',
+ canonical: false,
+ })
+
+ expect(result).not.toContainEqual({
+ tagName: 'link',
+ rel: 'canonical',
+ href: 'https://example.com',
+ })
+ })
+ })
+
+ describe('extend', () => {
+ it('should append extended meta descriptors', () => {
+ const result = createMeta({
+ title: 'Page',
+ description: 'Desc',
+ extend: [
+ { name: 'author', content: 'John Doe' },
+ { name: 'keywords', content: 'a, b, c' },
+ ],
+ })
+
+ expect(result).toContainEqual({
+ name: 'author',
+ content: 'John Doe',
+ })
+ expect(result).toContainEqual({
+ name: 'keywords',
+ content: 'a, b, c',
+ })
+ })
+ })
+})
diff --git a/packages/meta/tests/json-ld.test.ts b/packages/meta/tests/json-ld.test.ts
new file mode 100644
index 00000000000..0d002c99290
--- /dev/null
+++ b/packages/meta/tests/json-ld.test.ts
@@ -0,0 +1,438 @@
+import { describe, expect, it } from 'vitest'
+import { jsonLd } from '../src/json-ld/builders'
+
+describe('JSON-LD Builders', () => {
+ describe('jsonLd.create', () => {
+ it('should create a JSON-LD descriptor with single schema', () => {
+ const result = jsonLd.create({
+ '@type': 'WebSite',
+ name: 'My Site',
+ url: 'https://example.com',
+ })
+ expect(result).toEqual([
+ {
+ 'script:ld+json': {
+ '@context': 'https://schema.org',
+ '@type': 'WebSite',
+ name: 'My Site',
+ url: 'https://example.com',
+ },
+ },
+ ])
+ })
+
+ it('should create a JSON-LD descriptor with @graph for multiple schemas', () => {
+ const result = jsonLd.create([
+ { '@type': 'WebSite', name: 'My Site' },
+ { '@type': 'Organization', name: 'My Org' },
+ ])
+ expect(result).toEqual([
+ {
+ 'script:ld+json': {
+ '@context': 'https://schema.org',
+ '@graph': [
+ { '@type': 'WebSite', name: 'My Site' },
+ { '@type': 'Organization', name: 'My Org' },
+ ],
+ },
+ },
+ ])
+ })
+ })
+
+ describe('jsonLd.website', () => {
+ it('should create a WebSite schema', () => {
+ const result = jsonLd.website({
+ name: 'My Site',
+ url: 'https://example.com',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@context']).toBe('https://schema.org')
+ expect(schema['@type']).toBe('WebSite')
+ expect(schema.name).toBe('My Site')
+ expect(schema.url).toBe('https://example.com')
+ })
+
+ it('should include search action when searchUrl provided', () => {
+ const result = jsonLd.website({
+ name: 'My Site',
+ searchUrl: 'https://example.com/search?q={query}',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.potentialAction).toBeDefined()
+ expect(schema.potentialAction['@type']).toBe('SearchAction')
+ })
+ })
+
+ describe('jsonLd.organization', () => {
+ it('should create an Organization schema', () => {
+ const result = jsonLd.organization({
+ name: 'My Company',
+ url: 'https://example.com',
+ logo: 'https://example.com/logo.png',
+ socials: [
+ 'https://twitter.com/mycompany',
+ 'https://linkedin.com/company/mycompany',
+ ],
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('Organization')
+ expect(schema.name).toBe('My Company')
+ expect(schema.logo).toBe('https://example.com/logo.png')
+ expect(schema.sameAs).toEqual([
+ 'https://twitter.com/mycompany',
+ 'https://linkedin.com/company/mycompany',
+ ])
+ })
+
+ it('should include address when provided', () => {
+ const result = jsonLd.organization({
+ name: 'My Company',
+ address: {
+ street: '123 Main St',
+ city: 'City',
+ region: 'State',
+ postal: '12345',
+ country: 'US',
+ },
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.address['@type']).toBe('PostalAddress')
+ expect(schema.address.streetAddress).toBe('123 Main St')
+ })
+ })
+
+ describe('jsonLd.person', () => {
+ it('should create a Person schema', () => {
+ const result = jsonLd.person({
+ name: 'John Doe',
+ url: 'https://johndoe.com',
+ jobTitle: 'Software Engineer',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('Person')
+ expect(schema.name).toBe('John Doe')
+ expect(schema.jobTitle).toBe('Software Engineer')
+ })
+ })
+
+ describe('jsonLd.article', () => {
+ it('should create an Article schema', () => {
+ const result = jsonLd.article({
+ headline: 'Article Title',
+ description: 'Article description',
+ author: 'John Doe',
+ datePublished: '2024-01-15',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('Article')
+ expect(schema.headline).toBe('Article Title')
+ expect(schema.author['@type']).toBe('Person')
+ expect(schema.author.name).toBe('John Doe')
+ })
+
+ it('should handle author object', () => {
+ const result = jsonLd.article({
+ headline: 'Article Title',
+ author: { name: 'John Doe', url: 'https://johndoe.com' },
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.author.name).toBe('John Doe')
+ expect(schema.author.url).toBe('https://johndoe.com')
+ })
+
+ it('should allow custom article type', () => {
+ const result = jsonLd.article({
+ headline: 'Blog Post',
+ type: 'BlogPosting',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('BlogPosting')
+ })
+ })
+
+ describe('jsonLd.product', () => {
+ it('should create a Product schema', () => {
+ const result = jsonLd.product({
+ name: 'Cool Product',
+ description: 'A very cool product',
+ price: 99.99,
+ currency: 'USD',
+ availability: 'InStock',
+ brand: 'My Brand',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('Product')
+ expect(schema.name).toBe('Cool Product')
+ expect(schema.brand['@type']).toBe('Brand')
+ expect(schema.brand.name).toBe('My Brand')
+ expect(schema.offers.price).toBe(99.99)
+ expect(schema.offers.availability).toBe('https://schema.org/InStock')
+ })
+
+ it('should include rating when provided', () => {
+ const result = jsonLd.product({
+ name: 'Product',
+ rating: { value: 4.5, count: 100 },
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.aggregateRating['@type']).toBe('AggregateRating')
+ expect(schema.aggregateRating.ratingValue).toBe(4.5)
+ expect(schema.aggregateRating.ratingCount).toBe(100)
+ })
+ })
+
+ describe('jsonLd.breadcrumbs', () => {
+ it('should create a BreadcrumbList schema', () => {
+ const result = jsonLd.breadcrumbs([
+ { name: 'Home', url: 'https://example.com' },
+ { name: 'Category', url: 'https://example.com/category' },
+ { name: 'Product', url: 'https://example.com/category/product' },
+ ])
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('BreadcrumbList')
+ expect(schema.itemListElement).toHaveLength(3)
+ expect(schema.itemListElement[0].position).toBe(1)
+ expect(schema.itemListElement[0].name).toBe('Home')
+ expect(schema.itemListElement[2].position).toBe(3)
+ })
+ })
+
+ describe('jsonLd.faq', () => {
+ it('should create a FAQPage schema', () => {
+ const result = jsonLd.faq([
+ { question: 'What is X?', answer: 'X is...' },
+ { question: 'How do I Y?', answer: 'You can Y by...' },
+ ])
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('FAQPage')
+ expect(schema.mainEntity).toHaveLength(2)
+ expect(schema.mainEntity[0]['@type']).toBe('Question')
+ expect(schema.mainEntity[0].name).toBe('What is X?')
+ expect(schema.mainEntity[0].acceptedAnswer['@type']).toBe('Answer')
+ expect(schema.mainEntity[0].acceptedAnswer.text).toBe('X is...')
+ })
+ })
+
+ describe('jsonLd.event', () => {
+ it('should create an Event schema', () => {
+ const result = jsonLd.event({
+ name: 'Concert',
+ startDate: '2024-06-15T19:00:00-07:00',
+ location: 'Madison Square Garden',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('Event')
+ expect(schema.name).toBe('Concert')
+ expect(schema.location).toBe('Madison Square Garden')
+ })
+
+ it('should handle location object', () => {
+ const result = jsonLd.event({
+ name: 'Concert',
+ location: { name: 'Madison Square Garden', address: 'NYC' },
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.location['@type']).toBe('Place')
+ expect(schema.location.name).toBe('Madison Square Garden')
+ })
+
+ it('should handle virtual location', () => {
+ const result = jsonLd.event({
+ name: 'Webinar',
+ location: { name: 'Online', url: 'https://example.com/webinar' },
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.location['@type']).toBe('VirtualLocation')
+ })
+
+ it('should allow custom event type', () => {
+ const result = jsonLd.event({
+ name: 'Concert',
+ type: 'MusicEvent',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('MusicEvent')
+ })
+ })
+
+ describe('jsonLd.localBusiness', () => {
+ it('should create a LocalBusiness schema', () => {
+ const result = jsonLd.localBusiness({
+ name: 'My Restaurant',
+ type: 'Restaurant',
+ telephone: '+1-555-555-5555',
+ priceRange: '$$',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('Restaurant')
+ expect(schema.telephone).toBe('+1-555-555-5555')
+ expect(schema.priceRange).toBe('$$')
+ })
+
+ it('should handle string address', () => {
+ const result = jsonLd.localBusiness({
+ name: 'My Business',
+ address: '123 Main St, City, State 12345',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.address).toBe('123 Main St, City, State 12345')
+ })
+
+ it('should handle address object', () => {
+ const result = jsonLd.localBusiness({
+ name: 'My Business',
+ address: { street: '123 Main St', city: 'City' },
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.address['@type']).toBe('PostalAddress')
+ })
+ })
+
+ describe('jsonLd.softwareApp', () => {
+ it('should create a SoftwareApplication schema', () => {
+ const result = jsonLd.softwareApp({
+ name: 'My App',
+ type: 'MobileApplication',
+ operatingSystem: 'iOS, Android',
+ category: 'GameApplication',
+ price: 0,
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('MobileApplication')
+ expect(schema.applicationCategory).toBe('GameApplication')
+ expect(schema.offers.price).toBe(0)
+ })
+ })
+
+ describe('jsonLd.video', () => {
+ it('should create a VideoObject schema', () => {
+ const result = jsonLd.video({
+ name: 'Video Title',
+ description: 'Video description',
+ thumbnail: 'https://example.com/thumb.jpg',
+ uploadDate: '2024-01-15',
+ duration: 'PT5M30S',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('VideoObject')
+ expect(schema.name).toBe('Video Title')
+ expect(schema.thumbnailUrl).toBe('https://example.com/thumb.jpg')
+ expect(schema.duration).toBe('PT5M30S')
+ })
+ })
+
+ describe('jsonLd.recipe', () => {
+ it('should create a Recipe schema', () => {
+ const result = jsonLd.recipe({
+ name: 'Chocolate Cake',
+ description: 'Delicious cake',
+ author: 'Chef John',
+ prepTime: 'PT30M',
+ cookTime: 'PT1H',
+ servings: '8 servings',
+ ingredients: ['2 cups flour', '1 cup sugar'],
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('Recipe')
+ expect(schema.name).toBe('Chocolate Cake')
+ expect(schema.author['@type']).toBe('Person')
+ expect(schema.recipeIngredient).toHaveLength(2)
+ })
+
+ it('should handle string instructions', () => {
+ const result = jsonLd.recipe({
+ name: 'Simple Recipe',
+ instructions: 'Mix and bake',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.recipeInstructions).toBe('Mix and bake')
+ })
+
+ it('should handle array instructions', () => {
+ const result = jsonLd.recipe({
+ name: 'Simple Recipe',
+ instructions: ['Mix ingredients', 'Bake for 30 minutes'],
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.recipeInstructions).toHaveLength(2)
+ expect(schema.recipeInstructions[0]['@type']).toBe('HowToStep')
+ })
+ })
+
+ describe('jsonLd.course', () => {
+ it('should create a Course schema', () => {
+ const result = jsonLd.course({
+ name: 'Introduction to Programming',
+ description: 'Learn the basics',
+ provider: 'Online Academy',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('Course')
+ expect(schema.provider['@type']).toBe('Organization')
+ expect(schema.provider.name).toBe('Online Academy')
+ })
+ })
+
+ describe('jsonLd.howTo', () => {
+ it('should create a HowTo schema with string steps', () => {
+ const result = jsonLd.howTo({
+ name: 'How to Make Coffee',
+ steps: ['Boil water', 'Add coffee grounds', 'Pour water'],
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('HowTo')
+ expect(schema.step).toHaveLength(3)
+ expect(schema.step[0]['@type']).toBe('HowToStep')
+ expect(schema.step[0].text).toBe('Boil water')
+ })
+
+ it('should handle object steps', () => {
+ const result = jsonLd.howTo({
+ name: 'How to Make Coffee',
+ steps: [
+ { name: 'Step 1', text: 'Boil water', image: 'https://example.com/step1.jpg' },
+ ],
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema.step[0].name).toBe('Step 1')
+ expect(schema.step[0].image).toBe('https://example.com/step1.jpg')
+ })
+ })
+
+ describe('jsonLd.webpage', () => {
+ it('should create a WebPage schema', () => {
+ const result = jsonLd.webpage({
+ name: 'About Us',
+ description: 'Learn about our company',
+ url: 'https://example.com/about',
+ inLanguage: 'en',
+ })
+ const schema = (result[0] as any)['script:ld+json']
+ expect(schema['@type']).toBe('WebPage')
+ expect(schema.name).toBe('About Us')
+ expect(schema.inLanguage).toBe('en')
+ })
+ })
+})
+
+describe('jsonLd namespace', () => {
+ it('should export all builders', () => {
+ expect(typeof jsonLd.create).toBe('function')
+ expect(typeof jsonLd.website).toBe('function')
+ expect(typeof jsonLd.organization).toBe('function')
+ expect(typeof jsonLd.person).toBe('function')
+ expect(typeof jsonLd.article).toBe('function')
+ expect(typeof jsonLd.product).toBe('function')
+ expect(typeof jsonLd.breadcrumbs).toBe('function')
+ expect(typeof jsonLd.faq).toBe('function')
+ expect(typeof jsonLd.event).toBe('function')
+ expect(typeof jsonLd.localBusiness).toBe('function')
+ expect(typeof jsonLd.softwareApp).toBe('function')
+ expect(typeof jsonLd.video).toBe('function')
+ expect(typeof jsonLd.recipe).toBe('function')
+ expect(typeof jsonLd.course).toBe('function')
+ expect(typeof jsonLd.howTo).toBe('function')
+ expect(typeof jsonLd.webpage).toBe('function')
+ })
+})
diff --git a/packages/meta/tests/merge.test.ts b/packages/meta/tests/merge.test.ts
new file mode 100644
index 00000000000..979b120ca5f
--- /dev/null
+++ b/packages/meta/tests/merge.test.ts
@@ -0,0 +1,243 @@
+import { describe, expect, it } from 'vitest'
+import { mergeMeta, mergeMetaWith, excludeMeta, pickMeta } from '../src/merge'
+import { title, description, charset, canonical, openGraph, twitter } from '../src/builders'
+
+describe('mergeMeta', () => {
+ it('should merge multiple meta arrays', () => {
+ const result = mergeMeta(
+ title('Page Title'),
+ description('Description'),
+ charset(),
+ )
+ expect(result).toHaveLength(3)
+ })
+
+ it('should deduplicate by title', () => {
+ const result = mergeMeta(title('Title 1'), title('Title 2'))
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({ title: 'Title 2' })
+ })
+
+ it('should deduplicate by name attribute', () => {
+ const result = mergeMeta(
+ description('Description 1'),
+ description('Description 2'),
+ )
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({
+ name: 'description',
+ content: 'Description 2',
+ })
+ })
+
+ it('should deduplicate by property attribute', () => {
+ const result = mergeMeta(
+ openGraph({ title: 'Title 1' }),
+ openGraph({ title: 'Title 2' }),
+ )
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({ property: 'og:title', content: 'Title 2' })
+ })
+
+ it('should preserve order with later values winning', () => {
+ const result = mergeMeta(
+ [
+ { title: 'Title 1' },
+ { name: 'description', content: 'Desc 1' },
+ ],
+ [
+ { name: 'description', content: 'Desc 2' },
+ { name: 'keywords', content: 'a, b, c' },
+ ],
+ )
+ expect(result).toEqual([
+ { title: 'Title 1' },
+ { name: 'description', content: 'Desc 2' },
+ { name: 'keywords', content: 'a, b, c' },
+ ])
+ })
+
+ it('should handle null and undefined sources', () => {
+ const result = mergeMeta(title('Title'), null, undefined, description('Desc'))
+ expect(result).toHaveLength(2)
+ })
+
+ it('should deduplicate charset', () => {
+ const result = mergeMeta(charset(), charset())
+ expect(result).toHaveLength(1)
+ })
+
+ it('should deduplicate canonical links', () => {
+ const result = mergeMeta(
+ canonical('https://example.com/1'),
+ canonical('https://example.com/2'),
+ )
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({
+ tagName: 'link',
+ rel: 'canonical',
+ href: 'https://example.com/2',
+ })
+ })
+
+ it('should deduplicate JSON-LD by @type', () => {
+ const result = mergeMeta(
+ [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'WebSite', name: 'Site 1' } }],
+ [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'WebSite', name: 'Site 2' } }],
+ )
+ expect(result).toHaveLength(1)
+ expect((result[0] as any)['script:ld+json'].name).toBe('Site 2')
+ })
+
+ it('should not deduplicate different JSON-LD types', () => {
+ const result = mergeMeta(
+ [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'WebSite', name: 'Site' } }],
+ [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'Organization', name: 'Org' } }],
+ )
+ expect(result).toHaveLength(2)
+ })
+
+ it('should deduplicate JSON-LD by @id when present', () => {
+ const result = mergeMeta(
+ [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'WebSite', '@id': 'site', name: 'Site 1' } }],
+ [{ 'script:ld+json': { '@context': 'https://schema.org', '@type': 'WebSite', '@id': 'site', name: 'Site 2' } }],
+ )
+ expect(result).toHaveLength(1)
+ expect((result[0] as any)['script:ld+json'].name).toBe('Site 2')
+ })
+})
+
+describe('mergeMetaWith', () => {
+ it('should use first-wins strategy', () => {
+ const result = mergeMetaWith(
+ { strategy: 'first-wins' },
+ title('Title 1'),
+ title('Title 2'),
+ )
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({ title: 'Title 1' })
+ })
+
+ it('should use append strategy (no deduplication)', () => {
+ const result = mergeMetaWith(
+ { strategy: 'append' },
+ title('Title 1'),
+ title('Title 2'),
+ )
+ expect(result).toHaveLength(2)
+ expect(result[0]).toEqual({ title: 'Title 1' })
+ expect(result[1]).toEqual({ title: 'Title 2' })
+ })
+
+ it('should use last-wins strategy by default', () => {
+ const result = mergeMetaWith(
+ {},
+ title('Title 1'),
+ title('Title 2'),
+ )
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({ title: 'Title 2' })
+ })
+})
+
+describe('excludeMeta', () => {
+ it('should exclude meta by key', () => {
+ const meta = [
+ { title: 'Title' },
+ { name: 'description', content: 'Desc' },
+ { property: 'og:title', content: 'OG Title' },
+ ]
+ const result = excludeMeta(meta, ['title', 'og:title'])
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({ name: 'description', content: 'Desc' })
+ })
+
+ it('should handle twitter keys', () => {
+ const meta = [
+ { name: 'twitter:card', content: 'summary' },
+ { name: 'twitter:title', content: 'Title' },
+ { name: 'description', content: 'Desc' },
+ ]
+ const result = excludeMeta(meta, ['twitter:card'])
+ expect(result).toHaveLength(2)
+ expect(result).not.toContainEqual({
+ name: 'twitter:card',
+ content: 'summary',
+ })
+ })
+
+ it('should handle description key normalization', () => {
+ const meta = [
+ { title: 'Title' },
+ { name: 'description', content: 'Desc' },
+ ]
+ const result = excludeMeta(meta, ['description'])
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({ title: 'Title' })
+ })
+})
+
+describe('pickMeta', () => {
+ it('should pick meta by key', () => {
+ const meta = [
+ { title: 'Title' },
+ { name: 'description', content: 'Desc' },
+ { property: 'og:title', content: 'OG Title' },
+ { property: 'og:image', content: 'https://example.com/img.jpg' },
+ ]
+ const result = pickMeta(meta, ['title', 'og:title'])
+ expect(result).toHaveLength(2)
+ expect(result).toContainEqual({ title: 'Title' })
+ expect(result).toContainEqual({
+ property: 'og:title',
+ content: 'OG Title',
+ })
+ })
+
+ it('should pick description with normalized key', () => {
+ const meta = [
+ { title: 'Title' },
+ { name: 'description', content: 'Desc' },
+ ]
+ const result = pickMeta(meta, ['description'])
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({ name: 'description', content: 'Desc' })
+ })
+
+ it('should pick twitter keys', () => {
+ const meta = [
+ { name: 'twitter:card', content: 'summary' },
+ { name: 'twitter:title', content: 'Title' },
+ { name: 'description', content: 'Desc' },
+ ]
+ const result = pickMeta(meta, ['twitter:card', 'twitter:title'])
+ expect(result).toHaveLength(2)
+ })
+})
+
+describe('Composability', () => {
+ it('should allow spreading multiple helpers into meta array', () => {
+ const meta = [
+ ...charset(),
+ ...title('My Page'),
+ ...description('Description'),
+ ...openGraph({ title: 'My Page', type: 'website' }),
+ ...twitter({ card: 'summary' }),
+ ]
+ expect(meta.length).toBeGreaterThan(5)
+ })
+
+ it('should compose JSON-LD with regular meta', () => {
+ const regularMeta = [
+ ...title('Product Page'),
+ ...description('A great product'),
+ ]
+ const jsonLdMeta = [
+ { 'script:ld+json': { '@context': 'https://schema.org', '@type': 'Product', name: 'Great Product' } },
+ ]
+ const result = mergeMeta(regularMeta, jsonLdMeta)
+
+ expect(result).toContainEqual({ title: 'Product Page' })
+ expect(result.some((m) => 'script:ld+json' in m)).toBe(true)
+ })
+})
diff --git a/packages/meta/tsconfig.json b/packages/meta/tsconfig.json
new file mode 100644
index 00000000000..9b21ee123bc
--- /dev/null
+++ b/packages/meta/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.json",
+ "include": ["src", "vite.config.ts", "tests"]
+}
diff --git a/packages/meta/vite.config.ts b/packages/meta/vite.config.ts
new file mode 100644
index 00000000000..1dabb6bab87
--- /dev/null
+++ b/packages/meta/vite.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig, mergeConfig } from 'vitest/config'
+import { tanstackViteConfig } from '@tanstack/config/vite'
+import packageJson from './package.json'
+
+const config = defineConfig({
+ test: {
+ name: packageJson.name,
+ dir: './tests',
+ watch: false,
+ environment: 'jsdom',
+ typecheck: { enabled: true },
+ },
+})
+
+export default mergeConfig(
+ config,
+ tanstackViteConfig({
+ entry: ['./src/index.ts', './src/json-ld/index.ts'],
+ srcDir: './src',
+ }),
+)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3ab9c9061b6..3a6476f3216 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11233,6 +11233,12 @@ importers:
packages/history: {}
+ packages/meta:
+ devDependencies:
+ esbuild:
+ specifier: ^0.25.0
+ version: 0.25.10
+
packages/nitro-v2-vite-plugin:
dependencies:
nitropack:
@@ -11460,6 +11466,9 @@ importers:
'@tanstack/history':
specifier: workspace:*
version: link:../history
+ '@tanstack/meta':
+ specifier: workspace:*
+ version: link:../meta
'@tanstack/store':
specifier: ^0.8.0
version: 0.8.0
@@ -20961,9 +20970,6 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
- loupe@3.1.3:
- resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
-
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -29200,11 +29206,11 @@ snapshots:
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
- '@types/estree': 1.0.7
+ '@types/estree': 1.0.8
'@types/eslint@9.6.1':
dependencies:
- '@types/estree': 1.0.7
+ '@types/estree': 1.0.8
'@types/json-schema': 7.0.15
'@types/estree@1.0.7': {}
@@ -29885,7 +29891,7 @@ snapshots:
'@vitest/utils@3.0.6':
dependencies:
'@vitest/pretty-format': 3.0.6
- loupe: 3.1.3
+ loupe: 3.2.1
tinyrainbow: 2.0.0
'@vitest/utils@3.2.4':
@@ -30816,7 +30822,7 @@ snapshots:
assertion-error: 2.0.1
check-error: 2.1.1
deep-eql: 5.0.2
- loupe: 3.1.3
+ loupe: 3.2.1
pathval: 2.0.0
chalk@3.0.0:
@@ -32167,7 +32173,7 @@ snapshots:
estree-walker@3.0.3:
dependencies:
- '@types/estree': 1.0.7
+ '@types/estree': 1.0.8
esutils@2.0.3: {}
@@ -33126,7 +33132,7 @@ snapshots:
is-reference@1.2.1:
dependencies:
- '@types/estree': 1.0.7
+ '@types/estree': 1.0.8
is-regex@1.2.1:
dependencies:
@@ -33624,8 +33630,6 @@ snapshots:
dependencies:
js-tokens: 4.0.0
- loupe@3.1.3: {}
-
loupe@3.2.1: {}
lower-case@2.0.2: