OneApp Docs
PackagesFeatures

@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/seo

Type-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/seo

2. Add base metadata to your layout

app/layout.tsx
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

app/products/[id]/page.tsx
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

app/products/[id]/page.tsx
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:

app/sitemap.ts
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/seo

Build configuration: Uses tsdown with createDistConfig('react', ...) for distribution builds.


Technical Details

For Developers: Technical implementation details

Overview

PropertyValue
Locationpackages/seo
Dependenciesnext, schema-dts, @t3-oss/env-core
FrameworkNext.js 15+ (App Router)
Tests19 test files with comprehensive coverage
Type SafetyStrict TypeScript with schema-dts integration

Export Paths

PathDescription
@repo/seoMain exports (convenience re-exports)
@repo/seo/clientClient-side components (generic)
@repo/seo/serverServer-side utilities (generic)
@repo/seo/client/nextNext.js client components
@repo/seo/server/nextNext.js server utilities
@repo/seo/envEnvironment 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"
  }
});
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.com

Invalid 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` }]
  }
});

External Resources

Contributing

When adding new SEO features:

  1. Follow schema.org standards - use official schema types
  2. Add TypeScript types - leverage schema-dts for type safety
  3. Write tests - validate schema structure and metadata generation
  4. Update documentation - include usage examples
  5. Validate with Google - test with Rich Results Test

See packages/seo/README.md for detailed contributor guidelines.

On this page