# 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

```tsx
// 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)`

```ts
buildPortfolioJsonLd(item, { url, publisher? }): Record<string, unknown>
```

| Option           | Required         | What it is                                |
| ---------------- | ---------------- | ----------------------------------------- |
| `url`            | Yes              | The canonical URL of this case study page |
| `publisher.name` | With `publisher` | Your agency's name                        |
| `publisher.url`  | No               | Your agency's site                        |
| `publisher.logo` | No               | An 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:

| Property                  | From                                                                                                                          |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `headline`                | `seo.metaTitle`, else `title`                                                                                                 |
| `description`             | `seo.metaDescription`, else `description`. Left out when both are empty.                                                      |
| `image`                   | `[hero_image]`, when there's one                                                                                              |
| `url`, `mainEntityOfPage` | `options.url`                                                                                                                 |
| `datePublished`           | `published_at`                                                                                                                |
| `dateModified`            | `metrics_last_refreshed_at`, else `published_at`                                                                              |
| `keywords`                | `seo.keywords`, then `category`, then `services`, joined with commas                                                          |
| `author`, `publisher`     | The same `Organization` built from `options.publisher`, with the logo as an `ImageObject`. Both left out without `publisher`. |
| `about`                   | `details.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:

```json
{
  "@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:

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

## `jsonLdString(value)`

```ts
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:

```ts
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](https://sonor.dev/agency-site-kit/fetching).
