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>| 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:
{
"@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): stringJSON.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.