# Live updates and preview

Two route handlers connect your site to the Sonor dashboard. The webhook
regenerates the pages a change touched as soon as you publish, so a case
study goes live in seconds without a rebuild. The preview route lets the
dashboard's Preview button open an unpublished case study on your real site.
The setup CLI writes both.

## The webhook: `createRevalidateHandler`

```ts
// app/api/seo-revalidate/route.ts
import { createRevalidateHandler } from '@sonordev/agency-site-kit/revalidate';

export const POST = createRevalidateHandler({ basePath: '/work' });
```

Sonor calls `https://<your-domain>/api/seo-revalidate`, so mount it at exactly
that path. It covers every kind of content Sonor manages (managed copy, page
metadata, articles), not only case studies, so it's the only revalidation
route your site needs. Use it in place of site-kit's own
[revalidate route](https://sonor.dev/site-kit/live-updates), not beside it:
it's built on the same handler and adds the portfolio parts (your case study
index, detail pages by slug and the `portfolio` cache tag).

### Options

| Option        | Default                             | What it does                                                                                           |
| ------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `secret`      | `SONOR_API_KEY`, read on every call | The key callers must present. A string, or a function that returns one, called on every request.       |
| `basePath`    | `'/work'`                           | Where case studies live. Regenerated on every call, and each `slug` becomes `<basePath>/<slug>`.       |
| `defaultTags` | `['portfolio']`                     | Cache tags to revalidate when a call names none                                                        |
| `extraPaths`  | `[]`                                | More local paths to regenerate on every call, like `'/feed.xml'` or a home page that lists recent work |

`basePath` and every `extraPaths` entry must be a local path like `/work`.
Anything else throws a `TypeError` when the handler is created, so a typo
fails your build rather than a webhook call.

If your site also publishes Sonor articles, add the article index and its
feeds to `extraPaths` so they refresh with every publish:

```ts
export const POST = createRevalidateHandler({
  basePath: '/work',
  extraPaths: ['/insights', '/insights/rss.xml', '/insights/feed.xml'],
});
```

### What a call does

Sonor sends a `POST` with `Authorization: Bearer <SONOR_API_KEY>` and a JSON
body:

| Field            | Meaning                                                      |
| ---------------- | ------------------------------------------------------------ |
| `paths` / `path` | Local paths to regenerate                                    |
| `tags` / `tag`   | Cache tags to revalidate                                     |
| `slug` / `slugs` | Case studies to regenerate: each becomes `<basePath>/<slug>` |
| `revalidateAll`  | Regenerate the whole site from the root layout               |

Every accepted call regenerates `/sitemap.xml`, `/llms.txt`, `/llms-full.txt`,
`basePath` and your `extraPaths`, plus the paths and slugs it names. A call
with `revalidateAll`, or one naming only tags that include `seo`, regenerates
the root layout (and so every page) instead of individual paths.

Then it revalidates the tags the call names, or `defaultTags` when it names
none. Tags use Next's `'max'` profile: stale-while-revalidate. The next
visitor gets the cached page and triggers the rebuild, and everyone after
gets the new one.

### Responses

| Status | When                                                                                                                                                                                                         |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `200`  | `{ "revalidated": [...paths], "tags": [...tags] }`                                                                                                                                                           |
| `401`  | The bearer key is missing or wrong. The comparison is constant-time.                                                                                                                                         |
| `413`  | The body is over 16 KB                                                                                                                                                                                       |
| `400`  | Any path, tag or slug is malformed (a path that isn't a plain local path, dot segments, a query or fragment, a `[segment]`), or there are more than 100 paths or tags. One bad value refuses the whole call. |
| `500`  | A revalidation call failed inside Next                                                                                                                                                                       |

A refused call regenerates nothing. With `@sonordev/site-kit` 7.1 or later
installed, `{ "ping": true }` on its own regenerates nothing and answers
`{ "ok": true, "ping": true, "version": "…" }`, which Sonor uses to check the
route is installed.

### Check it

```bash
curl -s -X POST https://youragency.com/api/seo-revalidate \
  -H "Authorization: Bearer $SONOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"slug":"northwind-engineering"}'
```

A `200` listing `/work/northwind-engineering` means it works. A `404` means
the route isn't deployed at that path, and a `401` means the key on your site
doesn't match your project's key.

Without the route nothing breaks: your case studies still update once their
cached fetches expire, within an hour.

## Draft preview: `createPortfolioPreviewHandler`

```ts
// app/api/portfolio-preview/route.ts
import { createPortfolioPreviewHandler } from '@sonordev/agency-site-kit/preview';

export const { GET } = createPortfolioPreviewHandler({ basePath: '/work' });
```

It returns `{ GET }`, so export it by destructuring. The one option,
`basePath` (default `'/work'`), must match where your case study pages live.
It must be a local path like `/work`: anything else throws a `TypeError` when
the handler is created, so a typo fails your build rather than a preview.

### How it works

1. In the Sonor dashboard, someone presses Preview on a case study. Sonor
   issues a short-lived preview token and opens
   `/api/portfolio-preview?slug=<slug>&token=<token>` on your site.
2. The route checks the token by asking Sonor for that draft with it. If the
   draft comes back, the token is good.
3. It turns on Next's draft mode, stores the token in an `httpOnly` cookie
   (`sameSite: 'lax'`, `secure` on HTTPS, for one hour), and redirects to
   `<basePath>/<slug>?preview=1`.
4. On that page, `getPortfolioItem` sees draft mode and the cookie, and
   fetches the unpublished version with the token, uncached.

Tokens last an hour, so the cookie does too.

Every redirect is a relative `Location`, so the browser stays on the address
it asked for. The route never builds a full URL from the request, because
behind some hosts (Netlify among them) that can name the deploy's own
address rather than your domain, and the preview cookies don't exist there.
The `?preview=1` is there because Netlify adds the incoming query to a
redirect that has none, which would put the token in the address bar.
Nothing reads it.

| Request                               | Response                                                                  |
| ------------------------------------- | ------------------------------------------------------------------------- |
| `?slug=…&token=…`, valid              | A `307` to `<basePath>/<slug>?preview=1`, in draft mode                   |
| `?slug=…&token=…`, invalid or expired | `401` `{ "error": "Invalid or expired preview token" }`                   |
| No `slug` or no `token`               | `400` `{ "error": "Missing slug or token" }`                              |
| `?exit=1`                             | Leaves draft mode, clears the cookie, and redirects (`307`) to `basePath` |

Draft mode only affects `getPortfolioItem` (and `generatePortfolioMetadata`,
which reads through it): lists, categories and the sitemap always show
published work. To add a way out of preview, link to
`/api/portfolio-preview?exit=1`.

### Check it

```bash
curl -s -o /dev/null -D - "https://youragency.com/api/portfolio-preview?exit=1"
```

A `307` with `location: /work` (your `basePath`, maybe followed by the query
you sent) means the route is deployed. A `location` that names another host
means the site is on a version of the kit before 0.11.1.
