Fetching
The server side of the kit: the functions that load case studies from Sonor, the helpers that turn them into Next.js metadata, static params and sitemap entries, and the error contract that keeps an outage from ever looking like a missing page.
Everything here imports from @sonordev/agency-site-kit/portfolio/server
and runs on the server only (server components, generateMetadata,
generateStaticParams, route handlers, app/sitemap.ts). Importing it from a
client component is a build error, which is what keeps your API key out of
the browser.
Setup
The fetchers read SONOR_API_KEY from the environment and send it as the
x-api-key header. Sonor works out the project from the key, so there's no
project ID anywhere. SONOR_API_URL points them at a different API origin
(a staging API, say); leave it unset for production.
Caching
Every fetch is cached with Next's data cache and tagged portfolio
(PORTFOLIO_CACHE_TAG), so one tag revalidation refreshes the list, every
case study, the categories and the sitemap data at once. That's what the
live updates webhook does when you publish in Sonor.
| Data | Cached for |
|---|---|
Case studies, the list, categories, getPortfolioBrandConfig | 1 hour, or until the portfolio tag is revalidated |
| Proof records | 5 minutes, or until the portfolio tag is revalidated |
| A case study in draft preview | Not cached |
Case studies
getPortfolioItems(options?)
getPortfolioItems(options?: {
category?: string;
featured?: boolean;
limit?: number;
offset?: number;
}): Promise<PortfolioListResponse>
// { items: PortfolioItem[]; total: number; limit: number; offset: number }Published case studies, as list items (everything but the sections). Options
you leave out aren't sent. category is the stored category name, not the
URL slug (see slugifyCategory).
An empty portfolio is { items: [], ... }.
const PAGE_SIZE = 12;
const { items, total } = await getPortfolioItems({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE });
const featured = await getPortfolioItems({ featured: true, limit: 3 });getPortfolioItem(slug)
getPortfolioItem(slug: string): Promise<PortfolioItemFull | null>One case study with everything: its sections, its live analytics changes
(metricsDelta) and whether someone reordered its sections
(sectionsOrdered). It returns null only when the case study doesn't exist,
so map that to notFound():
const item = await getPortfolioItem(slug);
if (!item) notFound();On the way out it:
- checks the response's shape and throws
PortfolioContractErrorif the API sent something the kit can't safely render (see below), - strips raw client-site paths from Site Architecture sections (see Images, labels and paths),
- fills in defaults:
metricsDeltais[],sectionsOrderedisfalseandshowcase_sitesisnullwhen the API leaves them out, - serves drafts in preview: when the dashboard's Preview button put the browser in draft mode (see Live updates and preview), it fetches the unpublished version, uncached.
An empty slug returns null with a console warning, without a request.
getPortfolioCategories()
getPortfolioCategories(): Promise<string[]>The category names in use, as they're stored. Turn each into a URL with
slugifyCategory and into a label with formatCategoryLabel.
getProofRecords(slug, { site })
getProofRecords(slug: string, options: { site: string }): Promise<ProofRecord[]>The published evidence behind one case study's numbers, newest review first.
site is your own site's host ('youragency.com'): Sonor publishes records
per site. An empty list is a healthy answer, and so is anything that isn't a
list. See Proof and provenance for a
rendering example.
getPortfolioBrandConfig(options?)
getPortfolioBrandConfig(options?: { apiKey?: string; apiUrl?: string }): Promise<BrandConfig>Your agency's brand (colours, fonts, logo) as set in Sonor, merged over
DEFAULT_BRAND_CONFIG so every field is set. It's the one fetcher that never
throws: brand is cosmetic, so when Sonor can't be reached or sends no brand,
it returns DEFAULT_BRAND_CONFIG. apiKey and apiUrl override the
environment, as they do on the layout.
AgencySiteKitLayout fetches the brand through this same function, so you
only need it for something else, like an Open Graph image, and what you get
always matches the layout. See Brand layout.
Next.js helpers
generatePortfolioMetadata(slug)
// app/work/[slug]/page.tsx
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return generatePortfolioMetadata(slug);
}Returns { title, description, keywords, openGraph: { title, description, images, type: 'article' } }:
titleanddescriptionprefer the case study's SEO fields (seo.metaTitle,seo.metaDescription) over itstitleanddescription.keywordsisseo.keywords, left out when empty.openGraph.imagesis[hero_image], left out when there's no hero image.
It returns {} for a case study that doesn't exist, which is safe to return
from generateMetadata (the page itself calls notFound()). Spread it if you
want to add your own fields:
return { ...(await generatePortfolioMetadata(slug)), alternates: { canonical: `https://youragency.com/work/${slug}` } };generatePortfolioStaticParams()
export async function generateStaticParams() {
return generatePortfolioStaticParams(); // [{ slug: 'northwind-engineering' }, ...]
}One { slug } per published case study (the first 500).
generatePortfolioCategoryStaticParams()
// app/work/category/[category]/page.tsx
export async function generateStaticParams() {
return generatePortfolioCategoryStaticParams(); // [{ category: 'web-development' }, ...]
}One { category } per category, already slugified.
generatePortfolioIndexMetadata(options)
A stable title and canonical URL for the index, each category page and each page of results, so filtered and paginated views don't compete with each other in search.
// app/work/category/[category]/page.tsx
export async function generateMetadata({ params, searchParams }: Props) {
const { category } = await params;
const { page } = await searchParams;
return generatePortfolioIndexMetadata({
baseUrl: 'https://youragency.com',
category,
page: Number(page) || 1,
});
}| Option | Default | What it is |
|---|---|---|
baseUrl | Required | Your site's origin. A trailing slash is fine. |
basePath | '/work' | Where case studies live |
category | null | The category slug from the route |
page | 1 | The 1-based page number |
titleBase | 'Our Work' | The title before any category or page is added |
It returns { title, alternates: { canonical } }:
| Request | title | canonical |
|---|---|---|
| The index | Our Work | https://youragency.com/work |
| A category | Web Development | Our Work | https://youragency.com/work/category/web-development |
| Page 2 of a category | Web Development | Our Work — Page 2 | https://youragency.com/work/category/web-development?page=2 |
The category's name in the title is the stored name that matches the slug.
If no stored category matches, the title stays titleBase and the canonical
still uses the slug.
generatePortfolioSitemapEntries(options)
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { generatePortfolioSitemapEntries } from '@sonordev/agency-site-kit/portfolio/server';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
return [
{ url: 'https://youragency.com', priority: 1 },
...(await generatePortfolioSitemapEntries({ baseUrl: 'https://youragency.com' })),
];
}| Option | Default | What it is |
|---|---|---|
baseUrl | Required | Your site's origin |
basePath | '/work' | Where case studies live |
changeFrequency | 'monthly' | Written on every entry |
priority | 0.7 | Written on every entry |
One PortfolioSitemapEntry ({ url, lastModified?, changeFrequency?, priority? })
per published case study (the first 500). lastModified is when its live
metrics were last refreshed, else when it was published, and it's left out
when neither is known.
slugifyCategory(category)
The URL slug for a category name. See Images, labels and paths.
The error contract
Content fetchers never confuse "this doesn't exist" with "Sonor can't be reached right now". Treating an outage as a 404 would turn indexed case study URLs into real 404s the moment the API had a bad minute.
| What happened | What you get |
|---|---|
| The case study doesn't exist | getPortfolioItem returns null. Call notFound(). |
| Nothing is published, or nothing in that category | An empty list |
| A network error, or a 429, 502 or 503 | Retried with backoff, up to 3 attempts. If they all fail, PortfolioApiError is thrown. |
| Any other error status (a bad key, a 500) | PortfolioApiError, thrown on the first attempt |
| A response in a shape the kit can't render | PortfolioContractError (a PortfolioApiError too) |
SONOR_API_KEY isn't set | A configuration error from @sonordev/site-kit, thrown before any request |
That applies to every function on this page except
getPortfolioBrandConfig, which falls back to a default brand instead.
What a thrown error means in practice:
- At build time, the build fails loudly. You never ship an empty portfolio, an empty sitemap or pages missing their metadata.
- When a page regenerates in the background, Next keeps serving the last good version, so visitors and crawlers never notice.
- On a page's very first render, your nearest
error.tsxshows. The setup CLI writes one for the/workroutes.
PortfolioApiError and PortfolioContractError
Both are exported, so you can catch them when a missing piece shouldn't take the page down:
import type { ProofRecord } from '@sonordev/agency-site-kit/portfolio';
import { getProofRecords, PortfolioApiError } from '@sonordev/agency-site-kit/portfolio/server';
let records: ProofRecord[] = [];
try {
records = await getProofRecords(slug, { site: 'youragency.com' });
} catch (error) {
if (!(error instanceof PortfolioApiError)) throw error;
// Render without the evidence this time.
}| Error | Extra field |
|---|---|
PortfolioApiError | path: the API path that failed |
PortfolioContractError | path, and field: where the response broke the contract, e.g. sections[2].data |
PortfolioContractError fires when a case study is missing a non-empty id,
slug or title, when sections, services, kpis or metricsDelta is
present but not an array, or when a section has no sectionType or no data
object. assertPortfolioItemShape(item, path) runs the same check on data you
got some other way.
Lower-level API helpers
For a Sonor endpoint the kit doesn't wrap, the root entry
(@sonordev/agency-site-kit, server only) exports the fetcher the portfolio
functions are built on:
| Function | What it does |
|---|---|
apiFetch<T>(path, options?) | Sends x-api-key, parses JSON, and returns T, or null on any failure. It only throws with requireKey on and no key set. |
apiGet<T>(path, revalidate?, apiKey?, apiUrl?) | apiFetch with GET |
getApiConfig() | { apiUrl, apiKey } from the environment, or null (with a warning) when SONOR_API_KEY isn't set |
apiFetch takes the usual fetch options (method, body and so on) plus:
| Option | Default | What it does |
|---|---|---|
revalidate | 3600 | Seconds to cache the response. 0 turns caching off. |
tags | None | Next cache tags for the response |
headers | None | Extra headers |
apiKey, apiUrl | From the environment | Per-call overrides |
retry | false | Retry network errors and 429, 502 and 503 responses |
maxAttempts | site-kit's default | Attempts when retry is on |
requireKey | false | Throw when no key is configured, instead of returning null |
For example, the brand endpoint also answers with your agency's name as it's
set in Sonor, which getPortfolioBrandConfig doesn't return:
import { apiGet, type PortfolioConfigResponse } from '@sonordev/agency-site-kit';
const config = await apiGet<PortfolioConfigResponse>('/api/public/portfolio/config');
const agencyName = config?.orgName ?? 'Your Agency';