docs
    agency-site-kit: JSON-LD
    v0.11.1.md

    JSON-LD

    Each case study page should tell search engines and AI crawlers what it is. buildPortfolioJsonLd builds a schema.org Article from a case study, and jsonLdString turns it into text that's safe inside a <script> tag. It's a builder rather than a component, so you can put the object in your own script tag or merge it into a larger graph.

    Both import from @sonordev/agency-site-kit/portfolio.

    Add it to a case study page

    // app/work/[slug]/page.tsx
    import { notFound } from 'next/navigation';
    import { buildPortfolioJsonLd, jsonLdString } from '@sonordev/agency-site-kit/portfolio';
    import { getPortfolioItem } from '@sonordev/agency-site-kit/portfolio/server';
    
    export default async function CaseStudyPage({ params }: { params: Promise<{ slug: string }> }) {
      const { slug } = await params;
      const item = await getPortfolioItem(slug);
      if (!item) notFound();
    
      const jsonLd = buildPortfolioJsonLd(item, {
        url: `https://youragency.com/work/${item.slug}`,
        publisher: {
          name: 'Your Agency',
          url: 'https://youragency.com',
          logo: 'https://youragency.com/logo.png',
        },
      });
    
      return (
        <>
          <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(jsonLd) }} />
          {/* your case study */}
        </>
      );
    }

    The setup CLI's case study page already does this, with the --site-url and --agency-name you gave it.

    buildPortfolioJsonLd(item, options)

    buildPortfolioJsonLd(item, { url, publisher? }): Record<string, unknown>
    OptionRequiredWhat it is
    urlYesThe canonical URL of this case study page
    publisher.nameWith publisherYour agency's name
    publisher.urlNoYour agency's site
    publisher.logoNoAn absolute URL to your logo

    item can be a full case study or a list item: it reads title, description, seo, category, services, hero_image, published_at, metrics_last_refreshed_at and details. Here's where each property comes from:

    PropertyFrom
    headlineseo.metaTitle, else title
    descriptionseo.metaDescription, else description. Left out when both are empty.
    image[hero_image], when there's one
    url, mainEntityOfPageoptions.url
    datePublishedpublished_at
    dateModifiedmetrics_last_refreshed_at, else published_at
    keywordsseo.keywords, then category, then services, joined with commas
    author, publisherThe same Organization built from options.publisher, with the logo as an ImageObject. Both left out without publisher.
    aboutdetails.industry

    Anything empty is left out rather than written as an empty value. For a fictional case study, with a publisher that has no logo, the result looks like this:

    {
      "@context": "https://schema.org",
      "@type": "Article",
      "headline": "Northwind Engineering case study",
      "description": "How a faster site doubled Northwind's inbound requests.",
      "image": ["https://youragency.com/images/northwind-1600x1000.png"],
      "url": "https://youragency.com/work/northwind-engineering",
      "mainEntityOfPage": { "@type": "WebPage", "@id": "https://youragency.com/work/northwind-engineering" },
      "datePublished": "2026-09-01T00:00:00Z",
      "dateModified": "2026-09-20T00:00:00Z",
      "keywords": "engineering website, web-development, Web design",
      "author": { "@type": "Organization", "name": "Your Agency", "url": "https://youragency.com" },
      "publisher": { "@type": "Organization", "name": "Your Agency", "url": "https://youragency.com" },
      "about": "Civil engineering"
    }

    The category goes into keywords as it's stored (a slug like web-development). Map it through formatCategoryLabel first if you'd rather it read as words:

    const jsonLd = buildPortfolioJsonLd(
      { ...item, category: formatCategoryLabel(item.category) },
      { url, publisher },
    );

    jsonLdString(value)

    jsonLdString(value: unknown): string

    JSON.stringify, with every < escaped as <. A title or description containing </script> can then never close the tag it sits in. Always use it (or an equivalent escape) when you put JSON-LD in dangerouslySetInnerHTML.

    Combining it with other structured data

    Because it's a plain object, you can drop the @context and put it in a @graph next to your breadcrumbs or your organization:

    const { '@context': _context, ...article } = buildPortfolioJsonLd(item, { url, publisher });
    
    const graph = {
      '@context': 'https://schema.org',
      '@graph': [
        article,
        {
          '@type': 'BreadcrumbList',
          itemListElement: [
            { '@type': 'ListItem', position: 1, name: 'Work', item: 'https://youragency.com/work' },
            { '@type': 'ListItem', position: 2, name: item.title, item: url },
          ],
        },
      ],
    };
    
    <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(graph) }} />

    Page metadata (the <title>, description and Open Graph tags) comes from generatePortfolioMetadata. See Fetching.