Quickstart
The setup CLI adds a working portfolio to a Next.js App Router site in one command: the routes, a starter renderer you own, the webhook Sonor calls when content changes, and the route behind the dashboard's Preview button. This page covers its flags, the files it writes and the three things to finish by hand.
Before you start
You need a Next.js App Router project, the two packages, and your project's API key:
pnpm add @sonordev/agency-site-kit @sonordev/site-kit# .env.local
SONOR_API_KEY=sonor_xxxxxxxx_xxxxxxxxxxxxRun the CLI
From your project root:
npx agency-site-kit-setup --site-url https://youragency.com --agency-name "Your Agency"It looks for app/ first, then src/app/, and writes everything under the
one it finds. If there's neither, it stops with an error and writes nothing.
| Flag | Default | What it sets |
|---|---|---|
--base-path | /work | Where case studies live. --base-path /projects puts the index at /projects and each case study at /projects/<slug>. A trailing slash is dropped. |
--site-url | https://YOUR-SITE.com | Your site's origin, written into the index's canonical URLs and each case study's JSON-LD. A trailing slash is dropped. |
--agency-name | Your Agency | The publisher named in each case study's JSON-LD. |
Give each flag its value as the next argument (--base-path /projects,
not --base-path=/projects). If you leave out --site-url, search the
generated files for https://YOUR-SITE.com and replace it.
What it writes
With the default base path:
| File | What it is |
|---|---|
app/work/page.tsx | The index, with a stable title and canonical URL per page |
app/work/category/[category]/page.tsx | One page per category. They're real links, so crawlers find every case study |
app/work/[slug]/page.tsx | Each case study, statically generated, with Article JSON-LD |
app/work/error.tsx | What a visitor sees if Sonor can't be reached on a page's first render |
app/work/_components/CaseStudy.tsx | Your case study renderer: hero, challenges, strategy, results, testimonial, gallery and call to action |
app/work/_components/WorkGrid.tsx | Your index: category links, cards and pagination |
app/work/_components/work.module.css | Starter styles, built on the --sk-* brand variables |
app/api/seo-revalidate/route.ts | The webhook Sonor calls when content changes (see Live updates and preview) |
app/api/portfolio-preview/route.ts | The dashboard's Preview button (same page) |
The CLI never overwrites a file. Anything that already exists is reported as
skipped (exists) and left alone, so running it again only fills in what's
missing. The leading underscore on _components keeps that folder out of
routing.
Finish up
1. Wrap your layout
AgencySiteKitLayout fetches your brand from Sonor and publishes it as CSS
variables the starter styles read. It also gives site-kit's client modules
(analytics, forms, chat) the short-lived token they need.
// app/layout.tsx
import { AgencySiteKitLayout } from '@sonordev/agency-site-kit';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AgencySiteKitLayout>{children}</AgencySiteKitLayout>
</body>
</html>
);
}Already using site-kit's own SiteKitLayout? See Brand layout
for how the two fit together.
2. Add case studies to your sitemap
// 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> {
const work = await generatePortfolioSitemapEntries({ baseUrl: 'https://youragency.com' });
return [
{ url: 'https://youragency.com', changeFrequency: 'weekly', priority: 1 },
...work,
];
}Pass the same basePath you gave the CLI if it isn't /work.
3. Make it yours
CaseStudy.tsx and WorkGrid.tsx are plain server components with no
client JavaScript, and nothing in the kit depends on their class names or
markup. Restyle them, reorder them, or replace them. The kit keeps supplying
the data and the rules, whatever the page looks like.
The starter renders the seven sections most case studies carry. Every other
section's data is typed too (see Types), so adding one is a new
case in CaseStudy.tsx's switch. For example, a Lighthouse block for the
Performance section:
case 'portfolioPerformance': {
const data = section.data as Data<'portfolioPerformance'>;
if (!data.after) return null;
return (
<section className={styles.section}>
<h2>Performance</h2>
<p>
Performance {data.after.performance ?? '–'}, SEO {data.after.seo ?? '–'}, accessibility{' '}
{data.after.accessibility ?? '–'}
</p>
</section>
);
}If you add motion, keep it below the fold. The hero is the page's largest
paint and has to be visible in the server-rendered HTML. @sonordev/site-kit
has motion components built for that.
How the starter behaves
- Every page is a server component. The one client component is
error.tsx, because Next requires error boundaries to be. - The index shows 12 case studies a page, with
?page=2and so on for the rest. Category links appear once there's more than one category. The first two card images load eagerly and the rest lazily. - A category route carries a slug (
web-development).WorkGridlooks the slug up ingetPortfolioCategories()to get the stored name the API filters on. - Numbers carry their credit. Hero figures and result cards run through
curatePortfolioProofand printmetricNoteunder anything you didn't measure, and each card leads withheadlineKpi. See Proof and provenance. - A missing case study is a 404, an outage isn't.
getPortfolioItemreturnsnullfor a slug that doesn't exist and throws when Sonor can't be reached, so an already-built page keeps serving its last good copy. See Fetching.
Check it
Build, start the server, then fetch the index and look for real content in the HTML:
pnpm build && pnpm start
curl -s http://localhost:3000/work | grep -c '<h1'A count of zero means the page didn't render on the server. Once the site is deployed, open a draft case study in the Sonor dashboard and press Preview: it should open on your site with the draft showing.