Skip to content

Commit f1f3c64

Browse files
jorgemoyaclaude
andcommitted
feat(core): wrap layout-level guest queries in unstable_cache
Wrap home page, header, footer, SEO canonical, reCAPTCHA, and root layout metadata queries in unstable_cache for guest visitors. Authenticated requests continue to bypass the cache with no-store. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 009c3b5 commit f1f3c64

8 files changed

Lines changed: 196 additions & 75 deletions

File tree

.changeset/guest-cache-layout.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst-core": minor
3+
---
4+
5+
Wrap layout-level guest queries (home page, header, footer, SEO canonical, reCAPTCHA, root layout metadata) in `unstable_cache` with configurable revalidation. Authenticated requests continue to bypass the cache.

core/app/[locale]/(default)/page-data.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { unstable_cache } from 'next/cache';
12
import { cache } from 'react';
23

34
import { client } from '~/client';
@@ -77,15 +78,35 @@ const HomePageQuery = graphql(
7778
[FeaturedProductsCarouselFragment, FeaturedProductsListFragment],
7879
);
7980

80-
export const getPageData = cache(
81-
async (currencyCode?: CurrencyCode, customerAccessToken?: string) => {
81+
const getCachedPageData = unstable_cache(
82+
async (locale: string, currencyCode?: CurrencyCode) => {
8283
const { data } = await client.fetch({
8384
document: HomePageQuery,
84-
customerAccessToken,
8585
variables: { currencyCode },
86-
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } },
86+
locale,
87+
fetchOptions: { cache: 'no-store' },
8788
});
8889

8990
return data;
9091
},
92+
['home-page-data'],
93+
{ revalidate },
94+
);
95+
96+
export const getPageData = cache(
97+
async (locale: string, currencyCode?: CurrencyCode, customerAccessToken?: string) => {
98+
if (customerAccessToken) {
99+
const { data } = await client.fetch({
100+
document: HomePageQuery,
101+
customerAccessToken,
102+
variables: { currencyCode },
103+
locale,
104+
fetchOptions: { cache: 'no-store' },
105+
});
106+
107+
return data;
108+
}
109+
110+
return getCachedPageData(locale, currencyCode);
111+
},
91112
);

core/app/[locale]/(default)/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export default async function Home({ params }: Props) {
3838
const customerAccessToken = await getSessionCustomerAccessToken();
3939
const currencyCode = await getPreferredCurrencyCode();
4040

41-
return getPageData(currencyCode, customerAccessToken);
41+
return getPageData(locale, currencyCode, customerAccessToken);
4242
});
4343

4444
const streamableFeaturedProducts = Streamable.from(async () => {

core/app/[locale]/layout.tsx

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Analytics } from '@vercel/analytics/react';
22
import { SpeedInsights } from '@vercel/speed-insights/next';
33
import type { Metadata } from 'next';
4+
import { unstable_cache } from 'next/cache';
45
import { notFound } from 'next/navigation';
56
import { NextIntlClientProvider } from 'next-intl';
67
import { setRequestLocale } from 'next-intl/server';
@@ -53,15 +54,29 @@ const RootLayoutMetadataQuery = graphql(
5354
[WebAnalyticsFragment, ScriptsFragment],
5455
);
5556

56-
const fetchRootLayoutMetadata = cache(async () => {
57-
return await client.fetch({
58-
document: RootLayoutMetadataQuery,
59-
fetchOptions: { next: { revalidate } },
60-
});
57+
const getCachedRootLayoutMetadata = unstable_cache(
58+
async (locale: string) => {
59+
return await client.fetch({
60+
document: RootLayoutMetadataQuery,
61+
locale,
62+
fetchOptions: { cache: 'no-store' },
63+
});
64+
},
65+
['root-layout-metadata'],
66+
{ revalidate },
67+
);
68+
69+
const fetchRootLayoutMetadata = cache(async (locale: string) => {
70+
return getCachedRootLayoutMetadata(locale);
6171
});
6272

63-
export async function generateMetadata(): Promise<Metadata> {
64-
const { data } = await fetchRootLayoutMetadata();
73+
export async function generateMetadata({
74+
params,
75+
}: {
76+
params: Promise<{ locale: string }>;
77+
}): Promise<Metadata> {
78+
const { locale } = await params;
79+
const { data } = await fetchRootLayoutMetadata(locale);
6580

6681
const storeName = data.site.settings?.storeName ?? '';
6782

@@ -119,7 +134,7 @@ interface Props extends PropsWithChildren {
119134
export default async function RootLayout({ params, children }: Props) {
120135
const { locale } = await params;
121136

122-
const rootData = await fetchRootLayoutMetadata();
137+
const rootData = await fetchRootLayoutMetadata(locale);
123138
const toastNotificationCookieData = await getToastNotification();
124139

125140
if (!routing.locales.includes(locale)) {

core/components/footer/index.tsx

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
SiX,
77
SiYoutube,
88
} from '@icons-pack/react-simple-icons';
9-
import { getTranslations } from 'next-intl/server';
9+
import { unstable_cache } from 'next/cache';
10+
import { getLocale, getTranslations } from 'next-intl/server';
1011
import { cache, JSX } from 'react';
1112

1213
import { Streamable } from '@/vibes/soul/lib/streamable';
@@ -46,34 +47,63 @@ const socialIcons: Record<string, { icon: JSX.Element }> = {
4647
YouTube: { icon: <SiYoutube title="YouTube" /> },
4748
};
4849

49-
const getFooterSections = cache(
50-
async (customerAccessToken?: string, currencyCode?: CurrencyCode) => {
50+
const getCachedFooterSections = unstable_cache(
51+
async (locale: string, currencyCode?: CurrencyCode) => {
5152
const { data: response } = await client.fetch({
5253
document: GetLinksAndSectionsQuery,
53-
customerAccessToken,
5454
variables: { currencyCode },
55-
// Since this query is needed on every page, it's a good idea not to validate the customer access token.
56-
// The 'cache' function also caches errors, so we might get caught in a redirect loop if the cache saves an invalid token error response.
55+
locale,
5756
validateCustomerAccessToken: false,
58-
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } },
57+
fetchOptions: { cache: 'no-store' },
5958
});
6059

6160
return readFragment(FooterSectionsFragment, response).site;
6261
},
62+
['footer-sections'],
63+
{ revalidate },
6364
);
6465

65-
const getFooterData = cache(async () => {
66-
const { data: response } = await client.fetch({
67-
document: LayoutQuery,
68-
fetchOptions: { next: { revalidate } },
69-
});
66+
const getFooterSections = cache(
67+
async (locale: string, customerAccessToken?: string, currencyCode?: CurrencyCode) => {
68+
if (customerAccessToken) {
69+
const { data: response } = await client.fetch({
70+
document: GetLinksAndSectionsQuery,
71+
customerAccessToken,
72+
variables: { currencyCode },
73+
locale,
74+
validateCustomerAccessToken: false,
75+
fetchOptions: { cache: 'no-store' },
76+
});
77+
78+
return readFragment(FooterSectionsFragment, response).site;
79+
}
80+
81+
return getCachedFooterSections(locale, currencyCode);
82+
},
83+
);
84+
85+
const getCachedFooterData = unstable_cache(
86+
async (locale: string) => {
87+
const { data: response } = await client.fetch({
88+
document: LayoutQuery,
89+
locale,
90+
fetchOptions: { cache: 'no-store' },
91+
});
92+
93+
return readFragment(FooterFragment, response).site;
94+
},
95+
['footer-data'],
96+
{ revalidate },
97+
);
7098

71-
return readFragment(FooterFragment, response).site;
99+
const getFooterData = cache(async (locale: string) => {
100+
return getCachedFooterData(locale);
72101
});
73102

74103
export const Footer = async () => {
75104
const t = await getTranslations('Components.Footer');
76-
const data = await getFooterData();
105+
const locale = await getLocale();
106+
const data = await getFooterData(locale);
77107

78108
const logo = data.settings ? logoTransformer(data.settings) : '';
79109

@@ -96,7 +126,7 @@ export const Footer = async () => {
96126
const streamableSections = Streamable.from(async () => {
97127
const customerAccessToken = await getSessionCustomerAccessToken();
98128
const currencyCode = await getPreferredCurrencyCode();
99-
const sectionsData = await getFooterSections(customerAccessToken, currencyCode);
129+
const sectionsData = await getFooterSections(locale, customerAccessToken, currencyCode);
100130

101131
return [
102132
{

core/components/header/index.tsx

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { unstable_cache } from 'next/cache';
12
import { getLocale, getTranslations } from 'next-intl/server';
23
import { cache } from 'react';
34

@@ -47,34 +48,64 @@ const getCartCount = cache(async (cartId: string, customerAccessToken?: string)
4748
return response.data.site.cart?.lineItems.totalQuantity ?? null;
4849
});
4950

50-
const getHeaderLinks = cache(async (customerAccessToken?: string, currencyCode?: CurrencyCode) => {
51-
const { data: response } = await client.fetch({
52-
document: GetLinksAndSectionsQuery,
53-
customerAccessToken,
54-
variables: { currencyCode },
55-
// Since this query is needed on every page, it's a good idea not to validate the customer access token.
56-
// The 'cache' function also caches errors, so we might get caught in a redirect loop if the cache saves an invalid token error response.
57-
validateCustomerAccessToken: false,
58-
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } },
59-
});
60-
61-
return readFragment(HeaderLinksFragment, response).site;
62-
});
63-
64-
const getHeaderData = cache(async () => {
65-
const { data: response } = await client.fetch({
66-
document: LayoutQuery,
67-
fetchOptions: { next: { revalidate } },
68-
});
51+
const getCachedHeaderLinks = unstable_cache(
52+
async (locale: string, currencyCode?: CurrencyCode) => {
53+
const { data: response } = await client.fetch({
54+
document: GetLinksAndSectionsQuery,
55+
variables: { currencyCode },
56+
locale,
57+
validateCustomerAccessToken: false,
58+
fetchOptions: { cache: 'no-store' },
59+
});
60+
61+
return readFragment(HeaderLinksFragment, response).site;
62+
},
63+
['header-links'],
64+
{ revalidate },
65+
);
66+
67+
const getHeaderLinks = cache(
68+
async (locale: string, customerAccessToken?: string, currencyCode?: CurrencyCode) => {
69+
if (customerAccessToken) {
70+
const { data: response } = await client.fetch({
71+
document: GetLinksAndSectionsQuery,
72+
customerAccessToken,
73+
variables: { currencyCode },
74+
locale,
75+
validateCustomerAccessToken: false,
76+
fetchOptions: { cache: 'no-store' },
77+
});
78+
79+
return readFragment(HeaderLinksFragment, response).site;
80+
}
6981

70-
return readFragment(HeaderFragment, response).site;
82+
return getCachedHeaderLinks(locale, currencyCode);
83+
},
84+
);
85+
86+
const getCachedHeaderData = unstable_cache(
87+
async (locale: string) => {
88+
const { data: response } = await client.fetch({
89+
document: LayoutQuery,
90+
locale,
91+
fetchOptions: { cache: 'no-store' },
92+
});
93+
94+
return readFragment(HeaderFragment, response).site;
95+
},
96+
['header-data'],
97+
{ revalidate },
98+
);
99+
100+
const getHeaderData = cache(async (locale: string) => {
101+
return getCachedHeaderData(locale);
71102
});
72103

73104
export const Header = async () => {
74105
const t = await getTranslations('Components.Header');
75106
const locale = await getLocale();
76107

77-
const data = await getHeaderData();
108+
const data = await getHeaderData(locale);
78109

79110
const logo = data.settings ? logoTransformer(data.settings) : '';
80111

@@ -101,7 +132,8 @@ export const Header = async () => {
101132
]);
102133
// const customerAccessToken = await getSessionCustomerAccessToken();
103134
// const currencyCode = await getPreferredCurrencyCode();
104-
const categoryTree = (await getHeaderLinks(customerAccessToken, currencyCode)).categoryTree;
135+
const headerLinks = await getHeaderLinks(locale, customerAccessToken, currencyCode);
136+
const categoryTree = headerLinks.categoryTree;
105137

106138
/** To prevent the navigation menu from overflowing, we limit the number of categories to 6.
107139
To show a full list of categories, modify the `slice` method to remove the limit.
@@ -128,8 +160,8 @@ export const Header = async () => {
128160
getSessionCustomerAccessToken(),
129161
getPreferredCurrencyCode(),
130162
]);
131-
const giftCertificateSettings = (await getHeaderLinks(customerAccessToken, currencyCode))
132-
.settings?.giftCertificates;
163+
const headerLinksData = await getHeaderLinks(locale, customerAccessToken, currencyCode);
164+
const giftCertificateSettings = headerLinksData.settings?.giftCertificates;
133165

134166
return giftCertificateSettings?.isEnabled ?? false;
135167
});

core/lib/recaptcha.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'server-only';
22

3+
import { unstable_cache } from 'next/cache';
34
import { cache } from 'react';
45

56
import { client } from '~/client';
@@ -24,22 +25,30 @@ export const ReCaptchaSettingsQuery = graphql(`
2425
}
2526
`);
2627

27-
export const getReCaptchaSettings = cache(async (): Promise<ReCaptchaSettings | null> => {
28-
const { data } = await client.fetch({
29-
document: ReCaptchaSettingsQuery,
30-
fetchOptions: { next: { revalidate } },
31-
});
28+
const getCachedReCaptchaSettings = unstable_cache(
29+
async (): Promise<ReCaptchaSettings | null> => {
30+
const { data } = await client.fetch({
31+
document: ReCaptchaSettingsQuery,
32+
fetchOptions: { cache: 'no-store' },
33+
});
3234

33-
const reCaptcha = data.site.settings?.reCaptcha;
35+
const reCaptcha = data.site.settings?.reCaptcha;
3436

35-
if (!reCaptcha?.siteKey) {
36-
return null;
37-
}
37+
if (!reCaptcha?.siteKey) {
38+
return null;
39+
}
3840

39-
return {
40-
isEnabledOnStorefront: reCaptcha.isEnabledOnStorefront,
41-
siteKey: reCaptcha.siteKey,
42-
};
41+
return {
42+
isEnabledOnStorefront: reCaptcha.isEnabledOnStorefront,
43+
siteKey: reCaptcha.siteKey,
44+
};
45+
},
46+
['recaptcha-settings'],
47+
{ revalidate },
48+
);
49+
50+
export const getReCaptchaSettings = cache(async (): Promise<ReCaptchaSettings | null> => {
51+
return getCachedReCaptchaSettings();
4352
});
4453

4554
export const getRecaptchaSiteKey = cache(async (): Promise<string | undefined> => {

0 commit comments

Comments
 (0)