Do OG images not get cached?

I recently added an /api/og to create OG imges

import { ImageResponse } from "next/og";
import { mapSanityEvents } from "@/lib/map-sanity-event";
import {
  getOgMetaData,
  normalizeOgText,
  OG_DEFAULT_HOME_TITLE,
} from "./og-config";
import {
  getBlogPageOGData,
  getGenericPageOGData,
  getHomePageOGData,
  getSlugPageOGData,
  getUpcomingEventsForOg,
} from "./og-data";
import { OgEventsImage } from "./og-events-template";
import { getOgImageOptions } from "./og-fonts";
import { OgPlatformImage } from "./og-platform-template";
import {
  OgBrandedImage,
  OgErrorImage,
  OgSeoImage,
  type OgTemplateData,
} from "./og-template";

type ContentProps = Record<string, string>;

type OgFetcherResult = OgTemplateData & { seoImage?: string | null };

type OgPlatformItemResult = {
  title?: string | null;
  tagline?: string | null;
};

type PageOgResult = OgFetcherResult & {
  hasEventsListing?: boolean;
  hasPlatformList?: boolean;
  platformItems?: OgPlatformItemResult[] | null;
};

function mapPlatformItemsForOg(
  items: OgPlatformItemResult[] | null | undefined,
) {
  return (items ?? [])
    .map((item) => ({
      title: normalizeOgText(item?.title),
      tagline: normalizeOgText(item?.tagline) || null,
    }))
    .filter((item) => item.title.length > 0);
}

function renderPlatformOg(
  page: PageOgResult,
  options?: { defaultTitle?: string; fallbackTitle?: string },
) {
  if (!page.hasPlatformList) return null;

  const items = mapPlatformItemsForOg(page.platformItems);
  if (items.length === 0) return null;

  const title =
    normalizeOgText(page.title) ||
    options?.defaultTitle ||
    options?.fallbackTitle ||
    "Platform";

  return (
    <OgPlatformImage
      title={title}
      logo={normalizeOgText(page.logo) || undefined}
      items={items}
    />
  );
}

async function resolveOgContent(
  { id }: ContentProps,
  fetcher: (
    documentId: string,
  ) => Promise<[OgFetcherResult | null | undefined, string | undefined]>,
  options?: { defaultTitle?: string },
) {
  if (!id) return null;

  const [result, err] = await fetcher(id);
  if (err || !result) return null;

  const seoImage = normalizeOgText(result.seoImage);
  if (seoImage) {
    return <OgSeoImage seoImage={seoImage} />;
  }

  const title =
    normalizeOgText(result.title) || options?.defaultTitle || undefined;

  return (
    <OgBrandedImage
      description={result.description}
      image={result.image}
      logo={result.logo}
      title={title}
    />
  );
}

async function resolveSlugPageOgContent({ id }: ContentProps) {
  if (!id) return null;

  const [result, err] = await getSlugPageOGData(id);
  if (err || !result) return null;

  const page = result as PageOgResult;

  const seoImage = normalizeOgText(page.seoImage);
  if (seoImage) {
    return <OgSeoImage seoImage={seoImage} />;
  }

  if (page.hasEventsListing) {
    const [events, eventsErr] = await getUpcomingEventsForOg();
    if (!eventsErr && events && events.length > 0) {
      const upcoming = mapSanityEvents(events);
      return (
        <OgEventsImage
          title={normalizeOgText(page.title) || "Events"}
          logo={normalizeOgText(page.logo) || undefined}
          events={upcoming}
        />
      );
    }
  }

  const platformOg = renderPlatformOg(page, { fallbackTitle: "Platform" });
  if (platformOg) return platformOg;

  return (
    <OgBrandedImage
      description={page.description}
      image={page.image}
      logo={page.logo}
      title={page.title}
    />
  );
}

async function resolveHomePageOgContent({ id }: ContentProps) {
  if (!id) return null;

  const [result, err] = await getHomePageOGData(id);
  if (err || !result) return null;

  const page = result as PageOgResult;

  const seoImage = normalizeOgText(page.seoImage);
  if (seoImage) {
    return <OgSeoImage seoImage={seoImage} />;
  }

  const platformOg = renderPlatformOg(page, {
    defaultTitle: OG_DEFAULT_HOME_TITLE,
  });
  if (platformOg) return platformOg;

  const title =
    normalizeOgText(page.title) || OG_DEFAULT_HOME_TITLE || undefined;

  return (
    <OgBrandedImage
      description={page.description}
      image={page.image}
      logo={page.logo}
      title={title}
    />
  );
}

const getHomePageContent = (props: ContentProps) =>
  resolveHomePageOgContent(props);

const getSlugPageContent = (props: ContentProps) =>
  resolveSlugPageOgContent(props);

const getBlogPageContent = (props: ContentProps) =>
  resolveOgContent(props, getBlogPageOGData);

const getGenericPageContent = (props: ContentProps) =>
  resolveOgContent(props, getGenericPageOGData);

const block = {
  homePage: getHomePageContent,
  page: getSlugPageContent,
  blog: getBlogPageContent,
} as const;

export async function GET({ url }: Request): Promise<ImageResponse> {
  const { searchParams } = new URL(url);
  const type = searchParams.get("type") as keyof typeof block;
  const { width, height } = getOgMetaData(searchParams);
  const para = Object.fromEntries(searchParams.entries());
  const options = await getOgImageOptions({ width, height });
  const loadContent = block[type] ?? getGenericPageContent;

  try {
    const content = await loadContent(para);
    return new ImageResponse(content ?? <OgErrorImage />, options);
  } catch (error) {
    console.error("OG image generation failed:", error);
    return new ImageResponse(<OgErrorImage />, options);
  }
}

However, I was looking at my usage and dear lord 2 MB of ingress and egress for some images?!?!

Hi James,

For an /api/og route handler, I’d first check the response headers rather than assuming it is cached automatically. In the App Router, route handlers are not cached by default unless you opt into caching or return cache headers.

A quick test would be:

curl -I "https://your-domain.com/api/og?type=page&id=example"
curl -I "https://your-domain.com/api/og?type=page&id=example"

Look at cache-control and x-vercel-cache. If you see MISS/no useful cache headers each time, the image is probably being regenerated on requests.

For a generated OG image that can be reused, you can try adding cache headers to the ImageResponse:

return new ImageResponse(content ?? <OgErrorImage />, {
  ...options,
  headers: {
    "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
  },
})

If the image depends on Sanity content that changes, choose a shorter s-maxage, or include a version/hash in the query string when content changes so old social previews do not keep using the same cached URL.

One thing to watch: every unique query string is effectively a separate URL, so /api/og?id=1, /api/og?id=2, different widths/heights, etc. can each create separate cache entries and usage.