Skip to content
30 changes: 25 additions & 5 deletions app/blog/instagram/[...slug]/route.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,43 @@
// Dynamic 4:5 Instagram image for a blog post, as a Route Handler. See
// app/blog/og/[...slug] for the on-demand-ISR / static-export split.

import { generateBlogStaticParams } from 'utils/blog/generateBlogStaticParams';
import { getBlogPost } from 'utils/blog/getBlogPost';
import { renderBlogInstagramImage } from 'utils/og/blogInstagramImage';

const IS_EXPORT = process.env.EXPORT_MODE === 'static';

export const dynamic = 'force-static';
export const dynamicParams = false;
export const dynamicParams = !IS_EXPORT;

export function generateStaticParams() {
return generateBlogStaticParams('en');
return IS_EXPORT ? generateBlogStaticParams('en') : [];
}

export async function GET(
_request: Request,
{ params }: { params: { slug: string[] } },
) {
const slugPath = params.slug.join('/');
const { post } = await getBlogPost('en', slugPath);
// Tina's client defaults to errorPolicy: 'throw', so an unknown slug throws
// rather than returning { post: null } (same convention as app/blog/[...slug]).
let post: Awaited<ReturnType<typeof getBlogPost>>['post'];
try {
({ post } = await getBlogPost('en', slugPath));
} catch (error) {
console.error(
`Error fetching post for Instagram image: ${slugPath}`,
error,
);
post = null;
}
if (!post) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the og route: this guard is unreachable because getBlogPost throws on an unknown slug (Tina's client defaults to errorPolicy: 'throw'), so the request 500s rather than 404s. Needs the same try/catch that app/blog/[...slug]/page.tsx:42-56 already uses.

@isaaclombardssw isaaclombardssw Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Done, same fix.

// Unknown slug: 404 rather than render + ISR-cache a generic fallback image.
return new Response(null, { status: 404 });
}
return renderBlogInstagramImage({
title: post?.title ?? 'TinaCMS Blog',
author: post?.author,
title: post.title,
author: post.author,
seed: slugPath,
});
}
32 changes: 25 additions & 7 deletions app/blog/og/[...slug]/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,47 @@
// So we serve the image from a distinct prefix (/blog/og/<slug>) and point
// `openGraph.images` at it from the post's generateMetadata.
//
// force-static + generateStaticParams pre-renders one image per post at build
// (where the fonts/photos under public/ are readable via fs).
// Rendered on-demand and cached (ISR): the first request for a post's image
// renders it, then it's served from cache. A static export (output: 'export')
// can't generate on-demand, so it must prebuild every image instead — gated on
// the same EXPORT_MODE switch next.config.js uses. Fonts/photos load from
// public/ on disk with a live production-host fallback (see utils/og/ogAssets),
// so they resolve at build and at on-demand runtime.

import { generateBlogStaticParams } from 'utils/blog/generateBlogStaticParams';
import { getBlogPost } from 'utils/blog/getBlogPost';
import { renderBlogOgImage } from 'utils/og/blogOgImage';

const IS_EXPORT = process.env.EXPORT_MODE === 'static';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up: this gate can never be true — pre-existing bug, not introduced here.

package.json:13 is:

"export": "tinacms build && EXPORT_MODE=static,UNOPTIMIZED_IMAGES=true next build"

The comma makes that a single shell assignment, so EXPORT_MODE is the literal string "static,UNOPTIMIZED_IMAGES=true" and UNOPTIMIZED_IMAGES is never set at all. Verified:

$ sh -c 'EXPORT_MODE=static,UNOPTIMIZED_IMAGES=true node -e "console.log(process.env.EXPORT_MODE)"'
static,UNOPTIMIZED_IMAGES=true

So IS_EXPORT is always false — but so is isStatic in next.config.js:7, which means output: 'export' is never set either. Your gate is therefore self-consistent with next.config.js (both dead, both flip together if someone fixes the comma), so nothing breaks here and I wouldn't change it in this PR.

Worth knowing though: the PR description's "a static export still prebuilds every image" describes a path that currently can't execute or be tested. It also implies pnpm export / static-start's serve out hasn't worked for a while. Probably a separate follow-up (space, not comma).

@isaaclombardssw isaaclombardssw Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, pre-existing and self-consistent with next.config.js, so leaving it here. Separate follow-up to fix the comma (static UNOPTIMIZED_IMAGES=true) and confirm export/static-start still work.


export const dynamic = 'force-static';
export const dynamicParams = false;
export const dynamicParams = !IS_EXPORT;

export function generateStaticParams() {
return generateBlogStaticParams('en');
return IS_EXPORT ? generateBlogStaticParams('en') : [];
}

export async function GET(
_request: Request,
{ params }: { params: { slug: string[] } },
) {
const slugPath = params.slug.join('/');
const { post } = await getBlogPost('en', slugPath);
// Tina's client defaults to errorPolicy: 'throw', so an unknown slug throws
// rather than returning { post: null } (same convention as app/blog/[...slug]).
let post: Awaited<ReturnType<typeof getBlogPost>>['post'];
try {
({ post } = await getBlogPost('en', slugPath));
} catch (error) {
console.error(`Error fetching post for OG image: ${slugPath}`, error);
post = null;
}
if (!post) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This 404 guard is unreachable — unknown slugs will 500 instead.

getBlogPost throws on a missing record rather than returning { post: null }:

  • tina/__generated__/client.ts:3 calls createClient({ url, token, queries }) with no errorPolicy
  • tinacms/dist/client.js:159this.errorPolicy = errorPolicy || "throw"
  • tinacms/dist/client.js:248 — throws when json.errors is present
  • @tinacms/graphql/dist/index.js:6837 — a missing record throws NotFoundError, which lands in json.errors

So the throw propagates out of GET → 500, and this block never runs.

This path didn't exist before: dynamicParams = false meant Next 404'd at the routing layer before the handler ran. Making params dynamic created it. Realistic trigger is a deleted or renamed post whose OG URL is still live on social — the crawler gets a 500 and it shows up as error noise.

app/blog/[...slug]/page.tsx:42-56 already handles exactly this with try/catch → notFound(), so there's an established convention to follow:

let post;
try {
  ({ post } = await getBlogPost('en', slugPath));
} catch {
  post = null;
}
if (!post) {
  return new Response(null, { status: 404 });
}

Alternatively, validating slugPath against generateBlogStaticParams() before querying gives a true 404 and closes the amplification vector noted in the summary. Applies to all four routes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Done. Wrapped getBlogPost in try/catch → post = null → 404, matching the app/blog/[...slug]/page.tsx convention. Went with the try/catch over pre-validating against generateBlogStaticParams() to keep it minimal; the amplification vector is noted but low.

// Unknown slug: 404 rather than render + ISR-cache a generic fallback image.
return new Response(null, { status: 404 });
}
return renderBlogOgImage({
title: post?.title ?? 'TinaCMS Blog',
author: post?.author,
title: post.title,
author: post.author,
seed: slugPath,
});
}
27 changes: 22 additions & 5 deletions app/zh/blog/instagram/[...slug]/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,39 @@ import { generateBlogStaticParams } from 'utils/blog/generateBlogStaticParams';
import { getBlogPost } from 'utils/blog/getBlogPost';
import { renderBlogInstagramImage } from 'utils/og/blogInstagramImage';

const IS_EXPORT = process.env.EXPORT_MODE === 'static';

export const dynamic = 'force-static';
export const dynamicParams = false;
export const dynamicParams = !IS_EXPORT;

export function generateStaticParams() {
return generateBlogStaticParams('zh');
return IS_EXPORT ? generateBlogStaticParams('zh') : [];
}

export async function GET(
_request: Request,
{ params }: { params: { slug: string[] } },
) {
const slugPath = params.slug.join('/');
const { post } = await getBlogPost('zh', slugPath);
// Tina's client defaults to errorPolicy: 'throw', so an unknown slug throws
// rather than returning { post: null } (same convention as app/blog/[...slug]).
let post: Awaited<ReturnType<typeof getBlogPost>>['post'];
try {
({ post } = await getBlogPost('zh', slugPath));
} catch (error) {
console.error(
`Error fetching post for Instagram image: ${slugPath}`,
error,
);
post = null;
}
if (!post) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the og route: this guard is unreachable because getBlogPost throws on an unknown slug (Tina's client defaults to errorPolicy: 'throw'), so the request 500s rather than 404s. Needs the same try/catch that app/blog/[...slug]/page.tsx:42-56 already uses.

@isaaclombardssw isaaclombardssw Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Done, same fix.

// Unknown slug: 404 rather than render + ISR-cache a generic fallback image.
return new Response(null, { status: 404 });
}
return renderBlogInstagramImage({
title: post?.title ?? 'TinaCMS Blog',
author: post?.author,
title: post.title,
author: post.author,
seed: slugPath,
});
}
24 changes: 19 additions & 5 deletions app/zh/blog/og/[...slug]/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,36 @@ import { generateBlogStaticParams } from 'utils/blog/generateBlogStaticParams';
import { getBlogPost } from 'utils/blog/getBlogPost';
import { renderBlogOgImage } from 'utils/og/blogOgImage';

const IS_EXPORT = process.env.EXPORT_MODE === 'static';

export const dynamic = 'force-static';
export const dynamicParams = false;
export const dynamicParams = !IS_EXPORT;

export function generateStaticParams() {
return generateBlogStaticParams('zh');
return IS_EXPORT ? generateBlogStaticParams('zh') : [];
}

export async function GET(
_request: Request,
{ params }: { params: { slug: string[] } },
) {
const slugPath = params.slug.join('/');
const { post } = await getBlogPost('zh', slugPath);
// Tina's client defaults to errorPolicy: 'throw', so an unknown slug throws
// rather than returning { post: null } (same convention as app/blog/[...slug]).
let post: Awaited<ReturnType<typeof getBlogPost>>['post'];
try {
({ post } = await getBlogPost('zh', slugPath));
} catch (error) {
console.error(`Error fetching post for OG image: ${slugPath}`, error);
post = null;
}
if (!post) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the og route: this guard is unreachable because getBlogPost throws on an unknown slug (Tina's client defaults to errorPolicy: 'throw'), so the request 500s rather than 404s. Needs the same try/catch that app/blog/[...slug]/page.tsx:42-56 already uses.

@isaaclombardssw isaaclombardssw Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Done, same fix.

// Unknown slug: 404 rather than render + ISR-cache a generic fallback image.
return new Response(null, { status: 404 });
}
return renderBlogOgImage({
title: post?.title ?? 'TinaCMS Blog',
author: post?.author,
title: post.title,
author: post.author,
seed: slugPath,
});
}
8 changes: 4 additions & 4 deletions utils/og/blogInstagramImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ export async function renderBlogInstagramImage({
seed,
}: BlogInstagramInput): Promise<ImageResponse> {
const mappedAvatar = authorImagePath(author);
const avatarUri = mappedAvatar ? pngDataUri(mappedAvatar) : null;
const avatarUri = mappedAvatar ? await pngDataUri(mappedAvatar) : null;
const llamaSrc = pickLlama(seed);
const llamaUri = avatarUri ? null : pngDataUri(llamaSrc);
const llamaUri = avatarUri ? null : await pngDataUri(llamaSrc);
const llamaWidth = LLAMA_WIDTH[llamaSrc];

const logo = logoDataUri();
const logo = await logoDataUri();
const displayTitle = truncateTitle(title.trim() || 'TinaCMS Blog', TITLE_CAP);
const fontSize = pickFontSize(
displayTitle.length,
Expand Down Expand Up @@ -203,7 +203,7 @@ export async function renderBlogInstagramImage({
</div>,
{
...IG_SIZE,
fonts: ogFonts(),
fonts: await ogFonts(),
},
);
}
8 changes: 4 additions & 4 deletions utils/og/blogOgImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ export async function renderBlogOgImage({
seed,
}: BlogOgInput): Promise<ImageResponse> {
const mappedAvatar = authorImagePath(author);
const avatarUri = mappedAvatar ? pngDataUri(mappedAvatar) : null;
const avatarUri = mappedAvatar ? await pngDataUri(mappedAvatar) : null;
const llamaSrc = pickLlama(seed);
const llamaUri = avatarUri ? null : pngDataUri(llamaSrc);
const llamaUri = avatarUri ? null : await pngDataUri(llamaSrc);
const llamaLayout = LLAMA_LAYOUT[llamaSrc];

const logo = logoDataUri();
const logo = await logoDataUri();
const displayTitle = truncateTitle(title, TITLE_CAP);
const fontSize = pickFontSize(
displayTitle.length,
Expand Down Expand Up @@ -243,7 +243,7 @@ export async function renderBlogOgImage({
</div>,
{
...OG_SIZE,
fonts: ogFonts(),
fonts: await ogFonts(),
},
);
}
63 changes: 63 additions & 0 deletions utils/og/ogAssets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Covers the fail-loud asset loading. A genuinely-absent optional asset (404)
// resolves to null so the caller can fall back (llama), while a transient load
// failure (any non-404 / network error) rejects — so the render 500s and isn't
// ISR-cached, instead of silently shipping a degraded image that sticks for the
// life of the deployment. The module memoizes at module scope and derives its
// fetch origin from env at import, so each case runs in an isolated registry.
//
// Paths under /__missing__ don't exist in public/ on disk, so loadPublic falls
// through to the (mocked) origin fetch — no fs mock needed.

function loadOgAssets() {
// jest 25 doesn't recognise `node:`-prefixed builtins as core modules, so the
// real fs/path can't resolve under test. doMock both (non-hoisted, so ts-jest
// 25 doesn't choke on the hoist transform) before the module loads, so
// readFileSync always misses and every load goes through the origin fetch.
jest.doMock('node:fs', () => ({
readFileSync: () => {
throw new Error('ENOENT');
},
}));
jest.doMock('node:path', () => ({ join: (...p: string[]) => p.join('/') }));
// Set the production alias so the origin-fetch fallback is enabled.
process.env.VERCEL_PROJECT_PRODUCTION_URL = 'tina.io';
let mod: typeof import('./ogAssets');
jest.isolateModules(() => {
mod = require('./ogAssets');
});
return mod;
}

// AbortSignal.timeout exists on the Vercel runtime (Node 20+) but not jest 25's
// env; the mocked fetch ignores the signal anyway, so a no-op stub is enough.
beforeAll(() => {
if (typeof AbortSignal.timeout !== 'function') {
(AbortSignal as unknown as { timeout: () => AbortSignal }).timeout = () =>
new AbortController().signal;
}
});

afterEach(() => {
jest.resetModules();
global.fetch = undefined as unknown as typeof fetch;
});

describe('asset loading fail-loud semantics', () => {
it('resolves null for a genuinely-absent asset (404) so the caller can fall back', async () => {
global.fetch = jest.fn(
async () => ({ ok: false, status: 404 }) as Response,
);
const { pngDataUri } = loadOgAssets();
await expect(pngDataUri('/__missing__/nobody.png')).resolves.toBeNull();
});

it('rejects on a transient (non-404) fetch failure rather than degrading silently', async () => {
global.fetch = jest.fn(
async () => ({ ok: false, status: 503 }) as Response,
);
const { pngDataUri } = loadOgAssets();
await expect(pngDataUri('/__missing__/blip.png')).rejects.toThrow(
/asset fetch failed/i,
);
});
});
Loading
Loading