# OG Image Generation and Atomic Caching

This document covers how Open Graph (social-share preview) images are
generated, cached on disk, served, and invalidated for dealer pages.

Read this before modifying `lib/og-cache.ts`, `lib/og-image-service.ts`, or
any of the OG routes and invalidation call sites listed below. The atomic
write pattern in particular is load-bearing and must be preserved.

---

## 1. Why OG images are cached

Generating an OG image is expensive on two axes:

- **CPU:** each image is rendered via Next.js `ImageResponse` (Satori under
  the hood) from a React component with the dealer's hero image, logo, and
  content (`lib/og-image-service.ts:88-95`). Each call is a fresh
  text-layout pass plus PNG encoding.
- **Traffic pattern:** social scrapers (Facebook, LinkedIn, Slack,
  iMessage, Discord, Twitter, etc.) hit OG URLs aggressively and
  concurrently when a link is shared. Uncached this would fan out into many
  parallel render jobs, each consulting Prisma, for an asset whose content
  only changes at publish time.

Our strategy is generate-once, serve-many: render on first request (or at
publish time), persist to disk under `public/og-cache/`, and let subsequent
requests serve the file directly. A 24-hour `Cache-Control: public,
max-age=86400` header is set on the response
(`app/sites/[subdomain]/[domainSlug]/og/route.tsx:55`,
`app/sites/[subdomain]/[domainSlug]/og/[...slug]/route.tsx`) so CDNs and scrapers
further dampen traffic.

> Route paths: the dealer-site OG routes live under `app/sites/[subdomain]/[domainSlug]/og/…`
> (renamed from the old `app/dealers/…` tree along with the rest of the internal
> dealer-site routes - see `CLAUDE.md`). A separate `app/og/route.tsx` serves the
> platform/apex OG image. All OG routes set `X-Robots-Tag: noindex`.

Every image response also carries `X-Robots-Tag: noindex`: the `/og` URLs are
extensionless PNG responses referenced from `og:image` meta tags, and search
engines would otherwise crawl and index the raw binaries as "pages" (this was
polluting GSC's Page Indexing report). Because `public/og-cache/` files are
also directly routable as static assets (bypassing the routes), a
`next.config.mjs` `headers()` rule mirrors the same header onto
`/og-cache/:path*`. robots.txt deliberately does **not** block `/og` - a
Disallow would prevent crawlers from ever seeing the header. Social scrapers
ignore `noindex`, so link previews are unaffected. See `docs/SEO_INDEXING.md`
and PR #908.

---

## 2. Cache location and layout

**Root:** `public/og-cache/` (`lib/og-cache.ts:12`).

**Paths** (`lib/og-cache.ts:35-50`, `getOgCachePath`):

| Asset                 | Path                                                                  |
| --------------------- | --------------------------------------------------------------------- |
| Dealer homepage       | `public/og-cache/[subdomain]/[domainPrefix]-[domain]/index.png`       |
| CMS page (incl. blog) | `public/og-cache/[subdomain]/[domainPrefix]-[domain]/[slug].png`      |
| Blog post             | `public/og-cache/[subdomain]/[domainPrefix]-[domain]/blog/[slug].png` |

Where `[domainPrefix]-[domain]` is the `domainSlug` (e.g., `myamsoil-com`, `shopamsoil-ca`).
This namespacing prevents cache collisions when two dealers share the same subdomain on
different parent domains.

The blog variant is produced by prefixing the slug with `blog/` at call
sites (see `app/api/cms/publish/route.ts:206` and
`app/api/cms/pages/[id]/route.ts:411`) so that a blog post with the same
slug as a regular page doesn't collide.

**Public URL.** `getOgCacheUrl(subdomain, slug)` strips the leading
`public/` and returns a root-relative URL, e.g.
`/og-cache/jacks-amsoil/services.png`
(`lib/og-cache.ts:164-173`). Next.js serves anything under `public/` as a
static file, so the cached PNG is delivered without going through a route
handler once it exists.

**Routes that serve (and fill) the cache.**

- `app/sites/[subdomain]/[domainSlug]/og/route.tsx` - homepage OG image.
- `app/sites/[subdomain]/[domainSlug]/og/[...slug]/route.tsx` - CMS pages and blog
  posts.

Both follow the same pattern: `ogCacheExists` → serve from disk; otherwise
call `generateOgImage`, then serve the freshly-written file
(`app/sites/[subdomain]/[domainSlug]/og/route.tsx:46-66`). Both validate `subdomain`
and `slug` against path-traversal (`..`, `\0`, `/`, length caps) before
touching the filesystem
(`app/sites/[subdomain]/[domainSlug]/og/route.tsx:29-39`,
`app/sites/[subdomain]/[domainSlug]/og/[...slug]/route.tsx`). Any new route that
constructs an OG path from user-controlled input must do the same.

---

## 3. Atomic write pattern

The write in `saveOgCache` is deliberately not a direct `fs.writeFile` to
the destination (`lib/og-cache.ts:86-98`):

```ts
const tempPath = `${filePath}.${Date.now()}.tmp`;
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(tempPath, imageBuffer);
await fs.rename(tempPath, filePath);
```

**Why.** `writeFile` is not atomic. While it streams bytes, any concurrent
reader (`fs.readFile` in the serving route) can observe a partially-written
PNG, which decodes as garbage. Scrapers then cache the garbage, and we
cannot evict it from third-party caches on any sensible timescale.

`rename` on the same filesystem is atomic on POSIX. Readers either see the
old file (or `ENOENT`) or the complete new file - never halfway through.
This matters specifically because:

- Two social scrapers can hit the OG route within milliseconds of a
  publish, both triggering generation, both racing to write.
- The publish route regenerates dealer + page OG images in parallel while
  readers may simultaneously be requesting any of them
  (`app/api/cms/publish/route.ts:198-214`).

**The `Date.now()` suffix in `tempPath`** ensures two concurrent writers
don't clobber each other's temp files mid-write before their respective
renames land. The loser of the rename race produces a valid final file too
- whichever `rename` runs last wins and leaves a consistent PNG.

**Do not "simplify" this to a direct `writeFile`**, add a `fs.unlink` + `fs.writeFile`
sequence, or stream directly to the destination. Any of these reintroduces
the partial-read window.

Tests exercising these invariants live at `lib/__tests__/og-cache.test.ts`
and `lib/__tests__/og-image-service.test.ts`.

---

## 4. Cache invalidation

Invalidation is explicit - there is no TTL beyond the HTTP `max-age`. A
file sits in `public/og-cache/` until something calls `deleteOgCache` or
`saveOgCache` replaces it.

**Bust triggers (call sites of `deleteOgCache` /
`regenerate*OgImage`):**

- **Dealer settings update** - `app/api/dealer/update/route.ts:406-418`
  deletes the dealer's entire OG cache (homepage + all pages) when any
  OG-relevant field changes (business name, city, state, hide-address,
  logo, etc.). Fire-and-forget; failures are logged, not surfaced.
- **CMS publish** - `app/api/cms/publish/route.ts:192-214` calls
  `regenerateDealerOgImage` and `regeneratePageOgImage` for each published
  page. These internally pass `forceRegenerate: true`, bypassing the
  `ogCacheExists` short-circuit in `generateOgImage`
  (`lib/og-image-service.ts:33`). Fire-and-forget; publish response is not
  blocked on OG rendering.
- **CMS page delete** - `app/api/cms/pages/[id]/route.ts:409-419` deletes
  the single page's cache entry (with `blog/` prefix if applicable).
- **Subscription cancellation** - `app/api/webhooks/stripe/route.ts:762`
  deletes the dealer's entire OG cache (no slug) when their subscription
  is cancelled, which clears the homepage file and recursively removes the
  dealer's tuple subfolder (`lib/og-cache.ts:111-156`).

**Manual flush.** There is no admin endpoint or CLI for flushing. For a
one-off, deleting files or directories under `public/og-cache/` is
sufficient - the next request regenerates. Scrapers' external caches still
need to expire on their own schedule.

**Force vs. delete semantics.**

- `deleteOgCache(subdomain, slug?, domainPrefix?, domain?)` - remove one file,
  or (no slug) remove the homepage file _and_ that dealer's tuple subfolder
  `og-cache/{subdomain}/{prefix-domain}/`. The delete is deliberately
  tuple-scoped: it never recursively wipes the shared `og-cache/{subdomain}/`
  parent, because two dealers can share a subdomain across parent domains
  (myamsoil vs shopamsoil) under that directory. Called with no tuple it
  conservatively unlinks only the legacy top-level `{subdomain}.png` file and
  warns (`lib/og-cache.ts:111-156`). Next request regenerates on demand.
- `regenerateDealerOgImage` / `regeneratePageOgImage` - bypass the existence
  check and re-render immediately (`lib/og-image-service.ts:116-135`).
  Used at publish time so scrapers hitting immediately after a publish see
  fresh content without waiting for a first-request regeneration.

Use delete for "invalidate and let the next viewer pay the render cost."
Use regenerate for "we know this is stale and want the fresh version warm
now."

---

## 5. Extending OG generation

When modifying generation logic, the following must not change:

- **Atomic writes (section 3).** Keep the temp + rename. Do not introduce a
  direct write or a truncate-then-write sequence.
- **Path-traversal guards on the serving routes.** Any new OG route must
  reject `..`, `\0`, `/` in slug segments and bound total length, mirroring
  `app/sites/[subdomain]/[domainSlug]/og/[...slug]/route.tsx`.
- **Fire-and-forget at call sites.** OG regeneration hangs off publish,
  settings update, page delete, and subscription webhooks. In every case
  failures are logged, not raised. Do not make OG generation block the user
  flow - a failed render must not prevent a save.
- **Invalidation parity.** When you add a new write path that changes
  anything read by `lib/og-image-service.ts:38-67` (dealer fields queried,
  page `puckData`, hero slider, logo), add a corresponding `deleteOgCache`
  or `regenerate*OgImage` call. Otherwise viewers will see stale previews
  until the next unrelated update trips invalidation.
- **Blog slug prefix.** Blog posts are stored under `blog/[slug]` in the
  cache. Any new route, invalidator, or generator touching blog content
  must apply the same `blog/` prefix
  (see `app/api/cms/publish/route.ts:206`,
  `app/api/cms/pages/[id]/route.ts:411`).

Safe to change:

- The image layout, fonts, component tree, and `buildOgImageParams` logic.
  The cache key (`subdomain`, optional `slug`) is opaque to what's inside
  the PNG - changing the rendering only requires busting the cache once,
  which the next publish (or a manual delete) handles.
- The render size (currently `1200 × 630` -
  `lib/og-image-service.ts:89-90`). Social platforms are tolerant; a new
  dimension just needs a cache flush.
- The `Cache-Control` max-age on the route handlers, if you want scrapers
  to refetch more or less often.

---

## 6. Cross-references

- `docs/DEALER_EDITORS.md` - CMS publish flow and which fields trigger
  regeneration.
- `docs/plans/2026-01-29-og-image-redesign.md` - historical plan document
  describing the introduction of this caching layer.
- `lib/og-image-generator.ts`, `lib/og-image-component.ts` - layout and
  rendering internals (not covered here; stable API).
