docs
    agency-site-kit: Fetching
    v0.11.1.md

    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.

    DataCached for
    Case studies, the list, categories, getPortfolioBrandConfig1 hour, or until the portfolio tag is revalidated
    Proof records5 minutes, or until the portfolio tag is revalidated
    A case study in draft previewNot 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 PortfolioContractError if 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: metricsDelta is [], sectionsOrdered is false and showcase_sites is null when 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' } }:

    • title and description prefer the case study's SEO fields (seo.metaTitle, seo.metaDescription) over its title and description.
    • keywords is seo.keywords, left out when empty.
    • openGraph.images is [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,
      });
    }
    OptionDefaultWhat it is
    baseUrlRequiredYour site's origin. A trailing slash is fine.
    basePath'/work'Where case studies live
    categorynullThe category slug from the route
    page1The 1-based page number
    titleBase'Our Work'The title before any category or page is added

    It returns { title, alternates: { canonical } }:

    Requesttitlecanonical
    The indexOur Workhttps://youragency.com/work
    A categoryWeb Development | Our Workhttps://youragency.com/work/category/web-development
    Page 2 of a categoryWeb Development | Our Work — Page 2https://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' })),
      ];
    }
    OptionDefaultWhat it is
    baseUrlRequiredYour site's origin
    basePath'/work'Where case studies live
    changeFrequency'monthly'Written on every entry
    priority0.7Written 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 happenedWhat you get
    The case study doesn't existgetPortfolioItem returns null. Call notFound().
    Nothing is published, or nothing in that categoryAn empty list
    A network error, or a 429, 502 or 503Retried 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 renderPortfolioContractError (a PortfolioApiError too)
    SONOR_API_KEY isn't setA 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.tsx shows. The setup CLI writes one for the /work routes.

    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.
    }
    ErrorExtra field
    PortfolioApiErrorpath: the API path that failed
    PortfolioContractErrorpath, 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:

    FunctionWhat 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:

    OptionDefaultWhat it does
    revalidate3600Seconds to cache the response. 0 turns caching off.
    tagsNoneNext cache tags for the response
    headersNoneExtra headers
    apiKey, apiUrlFrom the environmentPer-call overrides
    retryfalseRetry network errors and 429, 502 and 503 responses
    maxAttemptssite-kit's defaultAttempts when retry is on
    requireKeyfalseThrow 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';