SEO setup

Wire up sitemap, robots, llms.txt, structured data, canonical URLs, and OG images on your Next.js site using the Inventra SDK.

This guide walks through adding modern SEO primitives to a Next.js App Router site using the Inventra SDK: sitemap, robots, llms.txt, Schema.org JSON-LD, canonical URLs, OpenGraph + Twitter cards, and dynamic OG images. Every step below is copy-paste-able — substitute your own strings.

Prerequisites

What you'll build

  • /sitemap.xml — static pages + blog posts from Inventra
  • /robots.txt — crawl rules pointing at the sitemap
  • /llms.txt + /llms.txt/<slug> — LLM-readable index and full-page Markdown
  • /api/og/[context]/[page] — dynamic OpenGraph images
  • Global metadataBase, canonical URLs, OG/Twitter cards, JSON-LD

Every factory referenced in the body — inventra, site, canonical, inventraOGTheme, buildSiteOGMetadata, buildContentOGMetadata, buildLlmsConfig, loadOGImage, createOGResponse, SiteOG, ContentOG — is defined at the bottom of this page under Functions reference. Click any factory name to jump straight to its source.

1. Install

npm install inventra
# or: yarn/pnpm/bun add inventra

2. Environment variables

Add these to your .env.local and validate in your env.ts:

INVENTRA_API_KEY=inventra_xxx
INVENTRA_ORG_ID=your-org-id
NEXT_PUBLIC_WEBSITE_URL=https://yoursite.com

NEXT_PUBLIC_WEBSITE_URL is the canonical origin of your site — Inventra reads it to build absolute URLs for sitemap entries, canonical tags, and the og:image references that social crawlers resolve. OG images in particular must be absolute (crawlers don't evaluate the host for you), so this value needs to match the origin you actually ship under.

See Production setup → Environment variables for the security rules around NEXT_PUBLIC_ prefixes.

3. Inventra config: a single source of truth

One file — packages/plugins/config.ts — holds everything downstream needs: the Inventra SDK client, the site metadata, the canonical helper, and the inventraOGTheme. Every Server Component, Route Handler, sitemap, and OG route imports from here.

// packages/plugins/config.ts (consumer-facing exports)
import { Inventra } from 'inventra';
import type { OGTheme } from 'inventra/og';
import { env } from '@/env';

export const inventra = new Inventra({
  apiKey: env.INVENTRA_API_KEY,
  orgId: env.INVENTRA_ORG_ID,
  apiUrl: env.NEXT_PUBLIC_WEBSITE_URL
});

export const site = {
  url: env.NEXT_PUBLIC_WEBSITE_URL.replace(/\/$/, ''),
  name: 'Acme',
  description: 'We build great things.',
  professional: {
    name: 'Acme',
    jobTitle: 'Design & Engineering Studio',
    description: 'Websites, software, and AI systems for growing businesses.'
  },
  priceRange: '$$'
} as const;

export const canonical = (p: string) =>
  `${site.url}${p.startsWith('/') ? p : `/${p}`}`;

export const inventraOGTheme: OGTheme = {
  /* colors, fonts, brand — see Functions reference for a full example */
};

Full source: Inventra config (#inventra-config). See also Production setup → Centralise your Inventra config for environment-specific guidance.

4. Sitemap

app/sitemap.ts — Next.js picks this up automatically and serves at /sitemap.xml.

// app/sitemap.ts
import { createSitemapGenerator, withISR } from 'inventra/next';
import { inventra, site } from '@/packages/plugins/config';

export default createSitemapGenerator(inventra, {
  baseUrl: site.url,
  staticRoutes: [
    { path: '/', priority: 1 },
    { path: '/about', priority: 0.8 },
    { path: '/contact', priority: 0.6 },
    { path: '/blog', priority: 0.9, changeFrequency: 'daily' }
  ],
  fetchConfig: withISR(3600)
});

5. Robots

app/robots.ts is the Next.js convention for serving /robots.txt — crawlers read it to decide which paths to index and where to find your sitemap. Allow everything by default, disallow the paths that shouldn't be public (admin, internal APIs), and point explicitly at the sitemap you generated in step 4.

// app/robots.ts
import type { MetadataRoute } from 'next';
import { site } from '@/packages/plugins/config';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/api'] }],
    sitemap: `${site.url}/sitemap.xml`,
    host: site.url
  };
}

6. llms.txt: Make AIs discover it

Two routes: the index and the per-page deep content. List every page you want included under autoPages and the SDK fetches, extracts, and converts them to Markdown automatically. Blog posts are always included.

pages vs autoPages — the config takes two collections. autoPages is an array of { path } entries; the SDK fetches the rendered HTML, runs Mozilla Readability, and converts the main content to Markdown. Stays in sync with your site automatically — use this for anything that already renders public HTML. pages is a map of hand-authored providers (getContent() returns explicit sections); use it only when you need fine-grained control or the source isn't a URL. Pass pages: {} if you only want auto-extraction. When a /llms.txt/<slug> request comes in, pages is checked first, then autoPages, then Inventra blog posts.

Both routes share the same config, so factor it into buildLlmsConfig (see Functions reference) and call it from each route:

// app/llms.txt/route.ts
import { createLlmsService } from 'inventra/llms';
import { withISR } from 'inventra/next';
import { buildLlmsConfig } from './config';

export const revalidate = 3600;

export async function GET() {
  const llms = createLlmsService(await buildLlmsConfig(), withISR(3600));
  return new Response(await llms.generateIndex(), {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' }
  });
}
// app/llms.txt/[...slug]/route.ts
import { createLlmsService } from 'inventra/llms';
import { withISR } from 'inventra/next';
import { buildLlmsConfig } from '../config';

export const revalidate = 3600;

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ slug: string[] }> }
) {
  const { slug } = await params;
  const llms = createLlmsService(await buildLlmsConfig(), withISR(3600));
  const body = await llms.generatePageContent(slug);
  if (body === null) return new Response('Not found', { status: 404 });
  return new Response(body, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' }
  });
}

7. Dynamic OpenGraph images

Every page — marketing, blog, docs — needs a 1200×630 preview image. Rather than hand-rolling Satori JSX for each one, Inventra ships a theme-driven OG plugin: one OGTheme powers shared layouts that render consistently across every route.

A minimal setup:

// app/api/og/[context]/[page]/route.tsx
import { inventraOGTheme } from '@/packages/plugins/config';
import { createOGResponse, SiteOG } from 'inventra/og';

const COPY: Record<string, { title: string; description: string }> = {
  home: { title: 'Your headline', description: 'Your subheadline' },
  about: { title: 'About Acme', description: 'Our story' }
};

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ page: string }> }
) {
  const { page } = await params;
  const copy = COPY[page];
  if (!copy) return new Response('Not found', { status: 404 });

  return createOGResponse(
    <SiteOG theme={inventraOGTheme} title={copy.title} description={copy.description} />,
    { theme: inventraOGTheme }
  );
}

For the full walkthrough — plugin install, theme config, blog variant that composites featured images, logo preloading — see OG images. Source for every plugin piece is also in Functions reference below.

8. Root layout: the global anchor

This is where metadataBase, template titles, defaults for OG/Twitter, site-wide JSON-LD, and analytics live. Every page inherits from here.

// app/layout.tsx
import { JsonLd } from 'inventra/react';
import { withISR } from 'inventra/next';
import {
  buildProfessionalServiceSchema,
  buildWebSiteSchema
} from 'inventra/schema';
import type { Metadata } from 'next';
import Script from 'next/script';
import { inventra, site } from '@/packages/plugins/config';

export const metadata: Metadata = {
  metadataBase: new URL(site.url),
  title: { default: site.name, template: `%s · ${site.name}` },
  description: site.description,
  alternates: { canonical: '/' },
  openGraph: {
    type: 'website',
    siteName: site.name,
    locale: 'en_US',
    url: site.url,
    images: [{ url: '/api/og/site/home', width: 1200, height: 630 }]
  },
  twitter: {
    card: 'summary_large_image',
    images: ['/api/og/site/home']
  }
};

export default async function RootLayout({
  children
}: {
  children: React.ReactNode;
}) {
  const org = await inventra.organizations.get(withISR(3600)).catch(() => null);
  const gaId = org?.integrations?.googleAnalyticsMeasurementId;

  return (
    <html lang="en">
      <head>
        <JsonLd
          data={[
            buildWebSiteSchema({
              siteUrl: site.url,
              professional: site.professional
            }),
            buildProfessionalServiceSchema({
              siteUrl: site.url,
              professional: site.professional,
              priceRange: site.priceRange,
              integrations: org?.integrations
            })
          ]}
        />
        {gaId && (
          <>
            <Script
              src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
              strategy="afterInteractive"
            />
            <Script id="ga-init" strategy="afterInteractive">
              {`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${gaId}');`}
            </Script>
          </>
        )}
      </head>
      <body>{children}</body>
    </html>
  );
}

For local businesses, adding your Google Business Profile (GBP) URL to the sameAs array of the ProfessionalService schema confirms to Google that the website and the GBP listing represent the same entity. This strengthens local search relevance and can unlock the Google Knowledge Panel.

The layout above already passes integrations: org?.integrations to buildProfessionalServiceSchema. The SDK detects the CID and emits sameAs for you — no per-site code is needed.

1. Find your CID

Open your listing on Google Maps and copy the URL. The CID appears as the digits after ?cid=:

https://www.google.com/maps/place/Your+Business/...?cid=12345678901234567890

If your URL contains a long 0x...!16s.../!19s... segment instead, the trailing decimal value (after !19s) is the CID.

2. Save it in Inventra

  1. Open the dashboard → your organization → Settings → Integrations
  2. Paste the CID into the Google Business CID field
  3. Click Save

The credential is encrypted at rest and exposed publicly only as part of the homepage's JSON-LD.

3. Verify

Wait for the layout's withISR(3600) cache to roll over (or redeploy), load the homepage, and view source. The ProfessionalService JSON-LD should now contain:

"sameAs": ["https://maps.google.com/?cid=12345678901234567890"]

If no CID is set, sameAs is omitted entirely — the schema stays valid (no empty array). When more sameAs-contributing integrations land, they're added inside the SDK and existing consumer sites pick them up automatically.

9. Per-page metadata

Each static page spreads buildSiteOGMetadata into its metadata export. The factory produces the openGraph + twitter fragments pointing at /api/og/site/<page>; the page contributes title, description, and canonical.

// app/about/page.tsx
import type { Metadata } from 'next';
import { canonical, site } from '@/packages/plugins/config';
import { buildSiteOGMetadata } from 'inventra/og';

const title = 'About Acme';
const description = 'Our story, team, and values.';

export const metadata: Metadata = {
  title,
  description,
  alternates: { canonical: canonical('/about') },
  ...buildSiteOGMetadata({
    page: 'about',
    title,
    description,
    baseUrl: site.url,
    path: '/about'
  })
};

Repeat for every static page — one entry in SITE_COPY (see Site OG route) and one buildSiteOGMetadata spread in the page's metadata.

10. Blog posts: JSON-LD, FAQ, references and breadcrumbs

Blog post pages do four things: emit metadata via buildContentOGMetadata (which handles type: 'article', publishedTime, authors, tags, and the absolute OG image URL), fetch the content from Inventra (which carries the post body, optional faq, and optional references), render Schema.org JSON-LD (Article + BreadcrumbList always; FAQPage when FAQ data exists; CreativeWork[] references nested under Article.citation when references exist), and render the visible FAQ and references sections.

The four JSON-LD pieces and where they come from:

Schema SDK builder Trigger
Article / BlogPosting buildArticleSchema Always emitted. Accepts citations to nest references inline.
BreadcrumbList buildBreadcrumbSchema Always emitted.
FAQPage buildFaqPageSchema Only when content.faq.questions.length > 0.
CreativeWork[] (citations) buildReferencesSchema Only when content.references.length > 0. Output is passed to buildArticleSchema via citations — never emitted as a top-level JSON-LD document.

The full one-shot example below wires all four. Each topic has a dedicated subsection further down with a drop-in component.

// app/blog/[slug]/page.tsx
import { JsonLd } from 'inventra/react';
import {
  buildArticleSchema,
  buildBreadcrumbSchema,
  buildFaqPageSchema,
  buildReferencesSchema
} from 'inventra/schema';
import { withISR } from 'inventra/next';
import { buildContentOGMetadata } from 'inventra/og';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { canonical, inventra, site } from '@/packages/plugins/config';
import { ContentFAQ } from '@/components/content-faq';
import { ContentReferences } from '@/components/content-references';

export const revalidate = 60;

export async function generateStaticParams() {
  const posts = await inventra.contents.list(withISR(60));
  return posts.map((post) => ({ slug: post.slug }));
}

export async function generateMetadata({
  params
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const content = await inventra.contents
    .getBySlug(slug, withISR(60))
    .catch(() => null);
  if (!content) return { title: 'Not Found' };

  const description =
    content.seoMetadata?.metaDescription ?? content.excerpt ?? '';

  return {
    title: content.title,
    description,
    alternates: { canonical: canonical(`/blog/${slug}`) },
    ...buildContentOGMetadata({
      content,
      description,
      baseUrl: site.url,
      path: `/blog/${slug}`
    })
  };
}

export default async function BlogPost({
  params
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await inventra.contents
    .getBySlug(slug, withISR(60))
    .catch(() => null);
  if (!post) notFound();

  // FAQ data (optional)
  const faqTitle = post.faq?.title ?? undefined;
  const faqItems = post.faq?.questions ?? [];
  const faqSchema =
    faqItems.length > 0 ? buildFaqPageSchema({ faq: faqItems }) : null;

  // References data (optional, attaches to Article.citation)
  const references = post.references ?? [];
  const citations =
    references.length > 0 ? buildReferencesSchema({ references }) : undefined;

  const publishedAtISO = post.publishedAt ?? null;
  const articleSchema = buildArticleSchema({
    type: 'BlogPosting',
    siteUrl: site.url,
    path: `/blog/${post.slug}`,
    headline: post.title,
    description: post.excerpt ?? null,
    image: post.featuredImageUrl ?? null,
    datePublished: publishedAtISO,
    dateModified: post.updatedAt ?? publishedAtISO,
    authorRef: { id: `${site.url}/#person` },
    publisherRef: { id: `${site.url}/#practice` },
    articleSection: post.categoryName ?? undefined,
    keywords: (post.tags ?? []).map((tag) => tag.value),
    citations
  });

  const breadcrumbSchema = buildBreadcrumbSchema({
    siteUrl: site.url,
    items: [
      { name: 'Home', path: '' },
      { name: 'Blog', path: '/blog' },
      { name: post.title, path: `/blog/${post.slug}` }
    ]
  });

  return (
    <>
      <JsonLd
        data={[
          articleSchema,
          breadcrumbSchema,
          ...(faqSchema ? [faqSchema] : [])
        ]}
      />
      <article>
        <h1>{post.title}</h1>
        {/* ...post body... */}

        {faqItems.length > 0 && (
          <ContentFAQ items={faqItems} title={faqTitle} />
        )}

        {references.length > 0 && <ContentReferences items={references} />}
      </article>
    </>
  );
}

The subsections below cover each piece in isolation — wiring snippet, optional getFaqs / getReferences SDK methods for fetching FAQ or references separately from the post payload, and (for references) the resulting JSON-LD shape.

Blog post FAQ

When a post has FAQ data attached, emit an additional FAQPage JSON-LD entry alongside the Article and render a visible FAQ section. Inventra stores FAQs on each content as content.faq with shape { title: string; questions: { question: string; answer: string }[] }. The SDK ships buildFaqPageSchema for the JSON-LD; rendering the visible accordion is up to your site (the SDK is data-only).

// app/blog/[slug]/page.tsx
import { JsonLd } from 'inventra/react';
import { buildFaqPageSchema } from 'inventra/schema';
import { ContentFAQ } from '@/components/content-faq';

const post = await inventra.contents.getBySlug(slug, withISR(60));

const faqTitle = post?.faq?.title ?? undefined;
const faqItems = post?.faq?.questions ?? [];
const faqSchema =
  faqItems.length > 0 ? buildFaqPageSchema({ faq: faqItems }) : null;

return (
  <>
    <JsonLd
      data={[articleSchema, breadcrumbSchema, ...(faqSchema ? [faqSchema] : [])]}
    />
    <article>
      {/* ...post body... */}
      {faqItems.length > 0 && (
        <ContentFAQ items={faqItems} title={faqTitle} />
      )}
    </article>
  </>
);

<ContentFAQ /> is your component — render an accordion (native <details>, shadcn Accordion, Radix, whatever your design system uses). The SDK only ships the JSON-LD builder.

If you need the FAQ payload without the full post (e.g. a sidebar panel that doesn't re-fetch the post), use inventra.contents.getFaqs(post.id).

Blog post references

Posts can carry an array of source citations on content.references with shape Array<{ source: string; url: string; asOf?: string; claim?: string }>. The SDK ships buildReferencesSchema which returns a CreativeWork[] array meant to be attached to the Article via the citations argument of buildArticleSchema — Schema.org's canonical pattern is Article.citation.

// app/blog/[slug]/page.tsx
import { JsonLd } from 'inventra/react';
import {
  buildArticleSchema,
  buildBreadcrumbSchema,
  buildReferencesSchema
} from 'inventra/schema';
import { ContentReferences } from '@/components/content-references';

const references = post?.references ?? [];
const citations =
  references.length > 0 ? buildReferencesSchema({ references }) : undefined;

const articleSchema = buildArticleSchema({
  /* ...other Article args... */
  citations
});

return (
  <>
    <JsonLd data={[articleSchema, breadcrumbSchema]} />
    <article>
      {/* ...post body... */}
      {references.length > 0 && <ContentReferences items={references} />}
    </article>
  </>
);

<ContentReferences /> is your component — typically an ordered list rendering source, optional date, URL, and optional claim. The SDK only ships data + schema.

The resulting JSON-LD nests each reference under the Article's citation property:

{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "citation": [
    {
      "@type": "CreativeWork",
      "name": "WHO Tobacco Fact Sheet",
      "url": "https://www.who.int/news-room/fact-sheets/detail/tobacco",
      "datePublished": "2025-12-01",
      "description": "Smoking causes cancer and cardiovascular disease."
    }
  ]
}

If you need the references list without the full post payload (e.g. a floating sources panel), use inventra.contents.getReferences(post.id).

11. Verify

Start npm run dev and check each endpoint:

URL Expect
/sitemap.xml Valid XML with static routes + blog entries
/robots.txt Allow /, Sitemap: https://yoursite.com/sitemap.xml
/llms.txt Plain text with ## Pages and ## Blog Posts sections
/llms.txt/home Full Markdown of the home page
/llms.txt/blog/<slug> Full Markdown of a blog post
/api/og/site/home 1200×630 PNG
/api/og/blog/<slug> 1200×630 PNG (composite with featured image when present)
Any blog post: view source <script type="application/ld+json"> with Article + BreadcrumbList
Blog post with FAQ: view source A second <script type="application/ld+json"> with "@type": "FAQPage" and one Question per FAQ entry
Blog post with references: view source The Article JSON-LD includes a citation array of CreativeWork objects (one per reference)
Any page: view source <link rel="canonical">, og:*, twitter:* meta tags
Homepage: view source (with Google Business CID set) ProfessionalService JSON-LD includes "sameAs": ["https://maps.google.com/?cid=<CID>"]

External validators:

12. Functions reference

Every consumer-owned file used above, with its real source. OG factories are imported from inventra/og — their API surface is summarized below; see OG images for the full walkthrough.

Suggested folder structure

app/
  api/
    og/
      [context]/[page]/route.tsx     ← site OG route
      blog/[slug]/route.tsx          ← blog OG route
  llms.txt/
    config.ts                        ← buildLlmsConfig
    route.ts                         ← llms.txt index
    [...slug]/route.ts               ← llms.txt per-page
  robots.ts
  sitemap.ts
  layout.tsx
packages/plugins/
  config.ts                          ← SDK client, site, canonical, OG theme

OG factories — SiteOG, ContentOG, createOGResponse, loadOGImage, buildSiteOGMetadata, buildContentOGMetadata — are imported from inventra/og. The reference entries below show the source so you know exactly what's happening under the hood.

Inventra config

File: packages/plugins/config.ts — the single source of truth for the SDK client, site metadata, canonical helper, and OG theme.

// packages/plugins/config.ts
import { Inventra } from 'inventra';
import type { OGTheme } from 'inventra/og';
import { env } from '@/env';

export const inventra = new Inventra({
  apiKey: env.INVENTRA_API_KEY,
  orgId: env.INVENTRA_ORG_ID,
  apiUrl: env.NEXT_PUBLIC_WEBSITE_URL
});

export const site = {
  url: env.NEXT_PUBLIC_WEBSITE_URL.replace(/\/$/, ''),
  name: 'Acme',
  description: 'We build great things.',
  professional: {
    name: 'Acme',
    jobTitle: 'Design & Engineering Studio',
    description: 'Websites, software, and AI systems for growing businesses.'
  },
  priceRange: '$$'
} as const;

export const canonical = (p: string) =>
  `${site.url}${p.startsWith('/') ? p : `/${p}`}`;

export const inventraOGTheme: OGTheme = {
  colors: {
    background: '#232323',
    text: '#F5F5F5',
    mutedText: '#A3A3A3',
    accent: '#D4A853',
    border: '#3A3A3A'
  },
  fonts: {
    regular: {
      name: 'Geist',
      url: 'https://cdn.jsdelivr.net/fontsource/fonts/geist@latest/latin-400-normal.ttf',
      weight: 400
    },
    bold: {
      name: 'GeistBold',
      url: 'https://cdn.jsdelivr.net/fontsource/fonts/geist@latest/latin-700-normal.ttf',
      weight: 700
    },
    heading: {
      name: 'SourceSerif4',
      url: 'https://cdn.jsdelivr.net/fontsource/fonts/source-serif-4@latest/latin-700-normal.ttf',
      weight: 700
    }
  },
  brand: {
    hostname: new URL(site.url).host,
    footerTagline: 'Acme — We build great things',
    logo: {
      url: `${site.url}/images/logo-light.png`,
      width: 140,
      height: 34
    }
  }
};

OG API surface

All of the following are imported from inventra/og — no local source to maintain. See OG images for the detailed walkthrough.

Export What it is
OGTheme, OGFontSpec, SiteOGProps, ContentOGProps Type surface for the theme + template props.
SiteOG Text-only template (title + description) used by marketing / docs OGs.
ContentOG Blog post template with optional featured-image composite.
createOGResponse(element, { theme, headers? }) Wraps next/og's ImageResponse with the theme's fonts and 1200×630 dimensions.
loadOGImage(url, { maxWidth?, quality? }) Fetches a remote image (WEBP/PNG/JPEG), resizes via sharp, returns a base64 data URL Satori always renders. Cached per URL.
loadOGFonts(fonts) Fetches and caches .ttf URLs for Satori. Usually called internally by createOGResponse — exposed if you want custom handling.
buildSiteOGMetadata({ page, title, description, baseUrl, path?, routePrefix? }) Returns { openGraph, twitter } pointing at /api/og/<routePrefix>/<page>.
buildContentOGMetadata({ content, baseUrl, description, path? }) Same shape, pointed at /api/og/blog/<slug>, emits type: 'article' + publishedTime + authors + tags.

inventra/og bundles sharp and date-fns as direct deps — npm install inventra and import.

Schema builders

JSON-LD factories from inventra/schema. All return plain objects (or arrays of plain objects) — pass them to <JsonLd data={[...]} /> from inventra/react.

Export What it is
buildArticleSchema({ ..., citations? }) Article / BlogPosting / TechArticle JSON-LD. Pass citations (from buildReferencesSchema) to attach a citation array.
buildBreadcrumbSchema({ siteUrl, items }) BreadcrumbList JSON-LD from a [{ name, path }] list.
buildFaqPageSchema({ faq }) FAQPage JSON-LD. Input: [{ question, answer }]. Markdown tokens are stripped from answers and whitespace is trimmed.
buildReferencesSchema({ references }) Returns CreativeWork[] — meant to be passed to buildArticleSchema as citations. Each item maps { source, url, asOf?, claim? }{ "@type": "CreativeWork", name, url, datePublished?, description? }.
buildProfessionalServiceSchema({ siteUrl, professional, integrations? }) ProfessionalService JSON-LD. Auto-derives sameAs from org.integrations (e.g. Google Business CID).
buildWebSiteSchema({ siteUrl, professional }) WebSite JSON-LD with publisher reference.

buildFaqPageSchema

import { buildFaqPageSchema } from 'inventra/schema';

const faqSchema = buildFaqPageSchema({
  faq: [
    { question: 'How long does it take?', answer: 'About 30 minutes.' }
  ]
});

Input shape matches the questions array on content.faq. Pair with inventra.contents.getFaqs(contentId) if you want to fetch FAQs separately from the post payload.

buildReferencesSchema

import {
  buildArticleSchema,
  buildReferencesSchema
} from 'inventra/schema';

const citations =
  references.length > 0 ? buildReferencesSchema({ references }) : undefined;

const articleSchema = buildArticleSchema({
  /* ...other Article args... */
  citations
});

The output is a CreativeWork[] — a citation collection, not a top-level JSON-LD document. Always plumb it into buildArticleSchema via the citations argument so the references render as Article.citation (Schema.org's canonical pattern). Use inventra.contents.getReferences(contentId) when you need the references list without the full post payload.

Site OG route

File: app/api/og/[context]/[page]/route.tsx — one catch-all that serves every marketing / static-page OG. Uses a local SITE_COPY map for per-page title + description, preloads the logo via loadOGImage, and hands everything to SiteOG.

import { inventraOGTheme } from '@/packages/plugins/config';
import { createOGResponse, loadOGImage, SiteOG } from 'inventra/og';

export const runtime = 'nodejs';

interface OGCopy {
  title: string;
  description: string;
}

const SITE_COPY: Record<string, OGCopy> = {
  home: { title: 'Your headline', description: 'Your subheadline' },
  about: { title: 'About Acme', description: 'Our story' }
  // ...one entry per page
};

const COPY: Record<string, Record<string, OGCopy>> = { site: SITE_COPY };

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ context: string; page: string }> }
) {
  const { context, page } = await params;
  const copy = COPY[context]?.[page];
  if (!copy) return new Response('Not found', { status: 404 });

  const logoDataUrl = await loadOGImage(inventraOGTheme.brand.logo?.url, {
    maxWidth: 320
  });

  return createOGResponse(
    <SiteOG
      theme={inventraOGTheme}
      title={copy.title}
      description={copy.description}
      logoDataUrl={logoDataUrl}
    />,
    { theme: inventraOGTheme }
  );
}

Blog OG route

File: app/api/og/blog/[slug]/route.tsx — fetches the Inventra content, resizes the featured image via loadOGImage, and composites everything into the branded frame. Posts without a featured image render the text-only variant automatically.

import { withISR } from 'inventra/next';
import { inventra, inventraOGTheme } from '@/packages/plugins/config';
import {
  ContentOG,
  createOGResponse,
  loadOGImage
} from 'inventra/og';

export const runtime = 'nodejs';

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
  const content = await inventra.contents
    .getBySlug(slug, withISR(60))
    .catch(() => null);
  if (!content) return new Response('Not found', { status: 404 });

  const [imageDataUrl, logoDataUrl] = await Promise.all([
    loadOGImage(content.featuredImageUrl),
    loadOGImage(inventraOGTheme.brand.logo?.url, { maxWidth: 320 })
  ]);

  return createOGResponse(
    <ContentOG
      theme={inventraOGTheme}
      eyebrow="Acme · Blog"
      title={content.title}
      author={content.authorName}
      publishedAt={content.publishedAt}
      imageDataUrl={imageDataUrl}
      logoDataUrl={logoDataUrl}
    />,
    {
      theme: inventraOGTheme,
      headers: {
        'Cache-Control':
          'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800'
      }
    }
  );
}

buildLlmsConfig

File: app/llms.txt/config.ts — builds the LlmsServiceConfig consumed by both /llms.txt and /llms.txt/[...slug]. The example below auto-discovers docs slugs from a local CMS; substitute your own source.

import type { LlmsAutoPageConfig, LlmsServiceConfig } from 'inventra/llms';
import { inventra, site } from '@/packages/plugins/config';

const STATIC_ROUTES: LlmsAutoPageConfig[] = [
  { path: '/' },
  { path: '/about' },
  { path: '/contact' },
  { path: '/products/sites' },
  { path: '/products/custom-software' },
  { path: '/products/consulting' },
  { path: '/products/ai-blog' }
];

export async function buildLlmsConfig(): Promise<
  Omit<LlmsServiceConfig, 'client'> & { client: typeof inventra }
> {
  return {
    client: inventra,
    siteName: site.name,
    siteDescription: site.description,
    baseUrl: site.url,
    pages: {},
    autoPages: STATIC_ROUTES
  };
}

Troubleshooting

Symptom Likely cause
/llms.txt has no ## Pages section autoPages extractions are all failing. Check your dev server log or use a probe route that calls extractPageAsMarkdown and inspects the error.
401 Unauthorized from SDK calls The API key is valid for a different environment than where the SDK is pointed. Set apiUrl on the Inventra client to match the environment that owns the key.
og:image resolves to a relative URL metadataBase isn't set on the root layout. Add metadataBase: new URL(site.url).
JSON-LD missing on blog posts Make sure you're rendering <JsonLd> inside the page component body (not just in metadata).
Cannot find module 'linkedom' Using Inventra SDK < 0.1.1. Upgrade: npm i inventra@latest.