Sander Korf
  • Portfolio
  • Resume

Sander Korf

Dutch Full Stack AI Engineer helping businesses cut costs through intelligent automation. 13+ years turning expensive manual processes into systems that work 24/7. Based in Amsterdam, available for freelance projects.

Navigation

  • Portfolio
  • Resume

Legal

  • Cookies
  • Privacy Policy
  • Terms and Conditions

Blog

Algolia

  • Algolia InstantSearch INP and object identity
  • Algolia merch by Sanity _type, not title
  • Algolia InstantSearch search key, not admin

Vercel

  • Next.js empty Suspense fallbacks wreck CLS P95
  • Vercel Workflows: skip closed eToro legs

eToro

  • eToro deploy vs rotate: idle cash not rotate
  • eToro search 404: query fields, not ticker
  • eToro v2 opens at-most-once, settlement
  • eToro LLM overlay ±10pp: critic cannot veto

Next.js

  • next-intl useTranslate skip links above fold
  • next-intl owns Klaviyo nl-NL email copy

Firebase & Expo

  • Staging deep links must not hijack production
  • Turn off Worklets Bundle Mode for EAS SHA-1
  • Reclaim Firebase orphan without password wipe

Centra & Klaviyo

  • Centra to Klaviyo tags need a plugin contract

About

  • Full Stack AI Engineer in Amsterdam

© 2026 Sander Korf. All rights reserved

94719489

  • Applied AI
  1. Home
  2. Blog
  3. next-intl useTranslate skip links above fold

next-intl useTranslate skip links above fold

I awaited one cached translation and the skip link suspended the fold again. Load that string synchronously inside a section already behind Suspense.

Sander KorfPublished August 31, 20263 min read
next-intlnextjssanityshopify

This was a Shopify homepage with Sanity sections

Next.js storefront. Sanity CMS stacked async sections. next-intl held the dictionary. Shopify sat under the cart. A skip-link is a tiny string. If it painted late, the fold jumped and a keyboard user waited for a word that was already in cache.

What they would notice if it failed

The carousel reserved height. Then a skip-link popped in. CLS on a homepage. Tab order that arrives after the picture. That is not a translation problem. That is an extra Suspense child.

The skip link that painted twice

I was shipping a Shopify homepage on Next.js. Sanity CMS sections are async server components. They sit behind a client <Suspense> error boundary. That boundary is the right place to wait for real I/O: Trustpilot, campaigns, Sanity.

I still called await translate('skipSlider') inside the section. translate is next-intl getTranslations. It is async. The row became a second async child of that <Suspense>.

The sized fallback painted. Then the skip-link streamed in. Layout jumped. INP stayed late. I had already reserved height for the slider. The skip-link was the extra paint. A visitor who wanted to tab past the carousel had to wait for a dictionary lookup that was already done.

Same cache, different kind of work

The dictionary was already in the next-intl request cache. Sanity keys were loaded. I was not fetching copy. I was awaiting a cached lookup.

useTranslate() reads the same cache. It is sync. No extra promise. No extra child. The skip-link can render with the fallback, not after it. That is the whole distinction: getTranslations suspends even when the messages are warm. useTranslate does not.

I kept making this mistake because the section was already async. One more await felt free. It is not free when the await is a sibling of the content that <Suspense> is holding. The boundary does not know that Trustpilot is the slow part and skipSlider is a string.

One term vs the whole tree

When the section still has real I/O (Trustpilot, campaigns, Sanity), I keep it async. I do not hoist the whole tree to sync just to label a skip-link. A tiny TranslatedTerm helper calls useTranslate for that one key. The helper is a leaf. The section can keep awaiting the work that actually needs a network.

When the whole component can be sync, I call useTranslate at the top. Search grid. Slider card. Rating. No helper needed. Those views already have every Sanity key they need. Making them async only to call getTranslations would recreate the same extra child.

Interpolated terms still go through translate with options. Counts, names, dates. useTranslate is for the single cached key that was turning the fold into a second suspend.

const TranslatedTerm = ({ term, fallback }: { term: string; fallback: string }) => {
	const translate = useTranslate();
	return translate(term, { fallback });
};
 
// skip link inside an async section; no extra suspend
return <TranslatedTerm term='skipSlider' fallback='Skip slider' />;

The demo below is local state. Toggle await vs useTranslate. Watch the skip-link arrive late or sit in the first paint.

Skip-link behind Suspense

Local fake state only. Await simulates getTranslations. Sync is useTranslate on the same dictionary.
Slider fallback (reserved height)
await getTranslationsskip-link pending

Sized fallback painted. Skip-link is still a pending async child of Suspense.


Happy coding!
Sander