@repo/seo
SEO metadata, structured data (JSON-LD), and sitemaps for Next.js. Generate Open Graph tags, Twitter Cards, and schema.org markup. Google sees your content correctly, social shares show rich previews, search rankings improve.
Quick Start
Add enterprise SEO in 5 minutes:
pnpm add @repo/seoType-safe metadata, automatic structured data, dynamic sitemaps. Skip to Quick Start →
Why @repo/seo?
Search engines can't rank your pages without metadata. Social media shows ugly links without Open Graph images.
E-commerce products don't appear in Google Shopping without structured data. Manual meta tags are error-prone (forgot
og:image on 20 pages?). Google Search Console shows "Missing field 'price'" errors.
@repo/seo solves this with type-safe metadata generation, structured data helpers, and automatic sitemap creation.
Production-ready with schema-dts validation, Next.js 15+ App Router support, 19 test files, and comprehensive structured data schemas.
Use cases
- E-commerce — Product schema for Google Shopping, ratings, prices, availability
- Content sites — Article schema with authors, publish dates, and breadcrumbs
- Local business — Location schema with hours, address, and maps integration
- SaaS marketing — Perfect social sharing with Open Graph images and Twitter Cards
- Multi-language sites — Localized metadata and sitemap alternates
How it works
@repo/seo provides type-safe metadata and structured data generation:
import { createMetadata, structuredData } from "@repo/seo/server/next";
import { JsonLd } from "@repo/seo/client/next";
// Type-safe metadata
export const metadata = createMetadata({
title: "Premium Headphones",
description: "High-quality wireless headphones with noise cancellation",
image: "/og-headphones.jpg", // Open Graph image
canonical: "https://example.com/products/headphones",
});
// Structured data for Google
const productSchema = structuredData.product({
name: "Premium Headphones",
offers: { price: "199.99", priceCurrency: "USD" }
});
// Renders JSON-LD in <head>
<JsonLd data={productSchema} />Uses schema-dts for type safety, Next.js metadata API for Open Graph/Twitter Cards, and schema.org for structured data.
Key features
Type-safe metadata — Autocomplete for all metadata fields, compile-time validation
Structured data — Product, Article, Organization, FAQ, Breadcrumb, LocalBusiness schemas
Open Graph + Twitter — Rich social previews with images, titles, descriptions
Dynamic sitemaps — Generate sitemaps from database, multi-language support
Schema validation — Built with schema-dts for correct structured data
Next.js 15+ optimized — App Router metadata API, Server Components
Quick Start
1. Install the package
pnpm add @repo/seo2. Add base metadata to your layout
import { createMetadata } from "@repo/seo/server/next";
export const metadata = createMetadata({
title: "My App",
description: "Welcome to our application",
image: "/og-image.png",
applicationName: "My App",
twitterHandle: "@yourhandle"
});3. Add dynamic metadata to a page
import { createMetadata } from "@repo/seo/server/next";
export async function generateMetadata({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return createMetadata({
title: product.name,
description: product.description,
image: product.image,
canonical: `https://example.com/products/${product.slug}`,
openGraph: {
type: "product",
images: [{ url: product.image, alt: product.name }]
}
});
}4. Add structured data for Google
import { JsonLd } from "@repo/seo/client/next";
import { structuredData } from "@repo/seo/server/next";
export default function ProductPage({ product }: { product: Product }) {
const productSchema = structuredData.product({
name: product.title,
description: product.description,
image: product.image,
offers: {
price: product.price.toString(),
priceCurrency: "USD",
availability: "https://schema.org/InStock",
},
brand: product.brand,
sku: product.sku,
});
return (
<>
<JsonLd data={productSchema} />
{/* Your product content */}
</>
);
}That's it! Your pages now have SEO metadata, Open Graph tags, and structured data for Google.
Generate sitemaps
Add a dynamic sitemap to help search engines find your pages:
import { generateSitemapObject } from "@repo/seo/server/next";
export default async function sitemap() {
const products = await getAllProducts();
return generateSitemapObject(
products.map((product) => ({
url: `https://example.com/products/${product.slug}`,
lastModified: product.updatedAt,
changeFrequency: "weekly",
priority: 0.8
}))
);
}Distribution
This package is available as @oneapp/seo for use outside the monorepo.
npm install @oneapp/seoBuild configuration: Uses tsdown with
createDistConfig('react', ...) for distribution builds.
Technical Details
For Developers: Technical implementation details
Overview
| Property | Value |
|---|---|
| Location | packages/seo |
| Dependencies | next, schema-dts, @t3-oss/env-core |
| Framework | Next.js 15+ (App Router) |
| Tests | 19 test files with comprehensive coverage |
| Type Safety | Strict TypeScript with schema-dts integration |
Export Paths
| Path | Description |
|---|---|
@repo/seo | Main exports (convenience re-exports) |
@repo/seo/client | Client-side components (generic) |
@repo/seo/server | Server-side utilities (generic) |
@repo/seo/client/next | Next.js client components |
@repo/seo/server/next | Next.js server utilities |
@repo/seo/env | Environment configuration |
## Metadata Generation
### Full Metadata Options
```typescript
import { createMetadata } from "@repo/seo/server/next";
export const metadata = createMetadata({
// Basic metadata
title: "Product Page | My Store",
description: "Buy the best products at great prices",
keywords: ["product", "store", "shopping"],
canonical: "https://example.com/products/item",
// Open Graph
// highlight-start
openGraph: {
title: "Amazing Product",
description: "Check out this amazing product",
images: [
{
url: "/og-image.jpg",
width: 1200,
height: 630,
alt: "Product Image",
},
],
type: "website",
siteName: "My Store",
locale: "en_US",
},
// highlight-end
// Twitter Card
twitter: {
card: "summary_large_image",
creator: "@mystore",
title: "Amazing Product",
description: "Check out this amazing product",
images: ["/twitter-image.jpg"],
},
// Robots
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
},
},
// Application metadata
applicationName: "My Store",
author: {
name: "Your Name",
url: "https://example.com",
},
publisher: "Your Company",
// Verification
verification: {
google: "google-site-verification-code",
yandex: "yandex-verification-code",
},
});Template Metadata
// app/layout.tsx
import { createMetadata } from "@repo/seo/server/next";
export const metadata = createMetadata({
// highlight-start
title: {
template: "%s | My Store",
default: "My Store - Best Products Online"
},
// highlight-end
description: "Welcome to My Store"
});
// app/products/page.tsx
export const metadata = {
title: "Products" // Becomes "Products | My Store"
};Alternate Languages
import { createMetadata } from "@repo/seo/server/next";
export const metadata = createMetadata({
title: "Multilingual Page",
description: "Available in multiple languages",
// highlight-start
alternates: {
canonical: "https://example.com/page",
languages: {
"en-US": "https://example.com/en/page",
"es-ES": "https://example.com/es/page",
"fr-FR": "https://example.com/fr/page"
}
}
// highlight-end
});Structured Data (JSON-LD)
Organization Schema
import { structuredData } from "@repo/seo/server/next";
import { JsonLd } from "@repo/seo/client/next";
const organizationSchema = structuredData.organization({
name: "My Company",
url: "https://example.com",
logo: "https://example.com/logo.png",
description: "We build amazing products",
contactPoint: {
telephone: "+1-800-555-0123",
contactType: "customer service",
},
sameAs: [
"https://twitter.com/mycompany",
"https://linkedin.com/company/mycompany",
],
});
<JsonLd data={organizationSchema} />;Article Schema
import { structuredData } from "@repo/seo/server/next";
const articleSchema = structuredData.article({
headline: "10 Tips for Better SEO",
description: "Learn how to improve your search rankings",
image: "https://example.com/article-image.jpg",
datePublished: "2024-01-15",
dateModified: "2024-01-20",
author: {
name: "John Doe",
url: "https://example.com/authors/john-doe"
},
publisher: {
name: "My Blog",
logo: {
url: "https://example.com/logo.png"
}
}
});Product Schema
import { structuredData } from "@repo/seo/server/next";
const productSchema = structuredData.product({
name: "Premium Headphones",
description: "High-quality wireless headphones",
image: "https://example.com/headphones.jpg",
// highlight-start
offers: {
price: "199.99",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
url: "https://example.com/products/headphones",
seller: {
name: "My Store"
}
},
// highlight-end
brand: "AudioTech",
sku: "AT-HP-001",
aggregateRating: {
ratingValue: "4.8",
reviewCount: "127"
}
});Breadcrumb Schema
import { structuredData } from "@repo/seo/server/next";
const breadcrumbSchema = structuredData.breadcrumb([
{ name: "Home", url: "https://example.com" },
{ name: "Products", url: "https://example.com/products" },
{ name: "Headphones", url: "https://example.com/products/headphones" },
{ name: "Premium Headphones" } // Current page (no URL)
]);FAQ Schema
import { structuredData } from "@repo/seo/server/next";
const faqSchema = structuredData.faq([
{
question: "What is your return policy?",
answer: "We offer 30-day returns on all products."
},
{
question: "Do you ship internationally?",
answer: "Yes, we ship to over 100 countries worldwide."
},
{
question: "How long does shipping take?",
answer: "Standard shipping takes 5-7 business days."
}
]);Local Business Schema
import { structuredData } from "@repo/seo/server/next";
const businessSchema = structuredData.localBusiness({
name: "My Coffee Shop",
description: "Artisanal coffee and pastries",
image: "https://example.com/shop.jpg",
address: {
streetAddress: "123 Main St",
addressLocality: "San Francisco",
addressRegion: "CA",
postalCode: "94102",
addressCountry: "US"
},
geo: {
latitude: "37.7749",
longitude: "-122.4194"
},
telephone: "+1-415-555-0123",
openingHours: "Mo-Su 07:00-20:00",
priceRange: "$$"
});Sitemap Generation
Dynamic Sitemap
// app/sitemap.ts
import { generateSitemapObject } from "@repo/seo/server/next";
import { getBaseUrl } from "@repo/seo/env";
export default async function sitemap() {
const products = await getAllProducts();
const posts = await getAllBlogPosts();
const baseUrl = getBaseUrl({ required: true });
// highlight-start
return generateSitemapObject([
{
url: baseUrl,
changeFrequency: "daily",
priority: 1,
lastModified: new Date()
},
...products.map((product) => ({
url: `${baseUrl}/products/${product.slug}`,
lastModified: product.updatedAt,
changeFrequency: "weekly" as const,
priority: 0.8,
images: [{ url: product.image, title: product.title }]
})),
...posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: "monthly" as const,
priority: 0.6
}))
]);
// highlight-end
}Multi-Language Sitemap
// app/sitemap.ts
import { generateSitemapObject } from "@repo/seo/server/next";
export default async function sitemap() {
const baseUrl = "https://example.com";
const locales = ["en", "es", "fr"];
const pages = ["about", "contact", "products"];
const sitemapEntries = pages.flatMap((page) =>
locales.map((locale) => ({
url: `${baseUrl}/${locale}/${page}`,
lastModified: new Date(),
// highlight-start
alternates: {
languages: Object.fromEntries(locales.map((l) => [l, `${baseUrl}/${l}/${page}`]))
}
// highlight-end
}))
);
return generateSitemapObject(sitemapEntries);
}Sitemap Index
// app/sitemap.ts (for large sites)
export default function sitemap() {
return [
{
url: "https://example.com/sitemap/products.xml",
lastModified: new Date()
},
{
url: "https://example.com/sitemap/blog.xml",
lastModified: new Date()
}
];
}Robots.txt
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/admin", "/api", "/private"]
},
{
userAgent: "Googlebot",
allow: "/",
disallow: "/admin",
crawlDelay: 2
}
],
sitemap: "https://example.com/sitemap.xml"
};
}Environment Configuration
// env.ts
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const env = createEnv({
server: {
// highlight-start
NEXT_PUBLIC_BASE_URL: z.string().url().optional(),
NEXT_PUBLIC_SITE_NAME: z.string().optional(),
NEXT_PUBLIC_SITE_DESCRIPTION: z.string().optional()
// highlight-end
},
runtimeEnv: process.env,
emptyStringAsUndefined: true
});Base URL Helper
import { getBaseUrl } from "@repo/seo/env";
// In development: http://localhost:3000
// In production: https://example.com (from env var or Vercel)
const baseUrl = getBaseUrl({ required: true });
// Use in metadata
export const metadata = {
metadataBase: new URL(baseUrl),
alternates: {
canonical: `${baseUrl}/page`
}
};Component Reference
JsonLd Component
import { JsonLd } from "@repo/seo/client/next";
export default function Page() {
return (
<>
{/* highlight-start */}
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Product",
name: "Product Name",
// ... more schema data
}}
/>
{/* highlight-end */}
{/* Your page content */}
</>
);
}Multiple Schemas
import { JsonLd } from "@repo/seo/client/next";
import { structuredData } from "@repo/seo/server/next";
export default function Page() {
const organizationSchema = structuredData.organization({ /* ... */ });
const productSchema = structuredData.product({ /* ... */ });
const breadcrumbSchema = structuredData.breadcrumb([ /* ... */ ]);
return (
<>
{/* highlight-start */}
<JsonLd data={organizationSchema} />
<JsonLd data={productSchema} />
<JsonLd data={breadcrumbSchema} />
{/* highlight-end */}
{/* Your page content */}
</>
);
}Best Practices
SEO Checklist
Essential SEO Elements
Ensure every page has these core elements for optimal search visibility.
// ✅ Complete SEO implementation
export const metadata = createMetadata({
// 1. Title (50-60 characters)
title: "Premium Headphones - AudioTech",
// 2. Description (150-160 characters)
description: "High-quality wireless headphones with noise cancellation. Free shipping. 30-day returns.",
// 3. Canonical URL
canonical: "https://example.com/products/headphones",
// 4. Open Graph image (1200x630)
image: "/og-headphones.jpg",
// 5. Keywords (optional, but helpful)
keywords: ["headphones", "wireless", "noise cancellation"],
// 6. Robots
robots: { index: true, follow: true }
});Performance Optimization
// ✅ Use static metadata when possible
export const metadata = createMetadata({
title: "Static Page",
description: "This metadata is generated at build time"
});
// ⚠️ Use dynamic metadata only when needed
export async function generateMetadata() {
const data = await fetchData(); // Adds request overhead
return createMetadata({ title: data.title });
}Structured Data Validation
Validate Your Schemas
Always test structured data with Google's Rich Results Test before deploying.
Image SEO
import { createMetadata } from "@repo/seo/server/next";
export const metadata = createMetadata({
title: "Product Page",
openGraph: {
images: [
{
// highlight-start
url: "/og-image.jpg",
width: 1200, // Recommended for Open Graph
height: 630,
alt: "Descriptive alt text for accessibility and SEO"
// highlight-end
}
]
}
});Testing
Metadata Tests
import { createMetadata } from "@repo/seo/server/next";
describe("SEO Metadata", () => {
it("generates correct metadata", () => {
const metadata = createMetadata({
title: "Test Page",
description: "Test description"
});
expect(metadata.title).toBe("Test Page");
expect(metadata.description).toBe("Test description");
});
});Structured Data Tests
import { structuredData } from "@repo/seo/server/next";
describe("Structured Data", () => {
it("generates valid Product schema", () => {
const schema = structuredData.product({
name: "Test Product",
offers: { price: "99.99", priceCurrency: "USD" }
});
expect(schema["@type"]).toBe("Product");
expect(schema.name).toBe("Test Product");
});
});Troubleshooting
Missing Base URL
// ❌ Error: Base URL not configured
const baseUrl = getBaseUrl({ required: true });
// Throws error if NEXT_PUBLIC_BASE_URL is not set
// ✅ Set environment variable
// .env.local
NEXT_PUBLIC_BASE_URL=https://example.comInvalid Structured Data
// ❌ Wrong: Missing required fields
const schema = structuredData.product({
name: "Product" // Missing offers
});
// ✅ Correct: All required fields
const schema = structuredData.product({
name: "Product",
offers: {
price: "99.99",
priceCurrency: "USD",
availability: "https://schema.org/InStock"
}
});Open Graph Images Not Showing
// ✅ Ensure absolute URLs for Open Graph images
import { getBaseUrl } from "@repo/seo/env";
const baseUrl = getBaseUrl({ required: true });
export const metadata = createMetadata({
title: "Product",
// highlight-next-line
image: `${baseUrl}/og-image.jpg`, // Use absolute URL
openGraph: {
images: [{ url: `${baseUrl}/og-image.jpg` }]
}
});Related Packages
- @repo/analytics - Track SEO performance
- @repo/storage - Host OG images with CDN
- @repo/internationalization - Multi-language SEO
External Resources
- Next.js Metadata API
- Schema.org Documentation
- Google Search Central
- Open Graph Protocol
- Twitter Card Validator
Contributing
When adding new SEO features:
- Follow schema.org standards - use official schema types
- Add TypeScript types - leverage
schema-dtsfor type safety - Write tests - validate schema structure and metadata generation
- Update documentation - include usage examples
- Validate with Google - test with Rich Results Test
See packages/seo/README.md for detailed contributor guidelines.