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.
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.
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.
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.
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
Sized fallback painted. Skip-link is still a pending async child of Suspense.
Happy coding!
Sander