I'd be happy to help you diagnose the slow First Contentful Paint (FCP) times in your App Router migration. Let me ask some targeted questions to identify potential issues:
loading.tsx
files for your routes?To properly diagnose the issue, please share:
// app/layout.tsx - your root layout file
// One of your slow-loading pages (app/[route]/page.tsx)
// Any loading.tsx files you have
// next.config.js/ts
Based on your description, here are the most likely culprits:
Missing Loading UI: Without loading.tsx
files, dynamic routes can't be partially prefetched, causing the client to wait for the full server response 1.
Client Component Overuse: If you're using "use client"
extensively, you're not getting the streaming benefits of Server Components.
Data Fetching Waterfalls: Sequential data fetching in components can create the same waterfalls you had in Pages Router.
Blocking Operations: Heavy computations or slow database queries in Server Components without proper streaming setup.
"use client"
?fetch()
in Server Components or client-side hooks?Please share the code above, and I'll be able to give you specific recommendations for improving your FCP times!
root layout ``` import { DeferredUserConsent } from './_components/Blocks/UserConsent/DeferredUserConsent'; import { getInternationalizedStaticUrls } from './_utils/getInternationalizedStaticUrls'; import { Footer } from 'app/_components/Layout/Footer/Footer'; import { Header } from 'app/_components/Layout/Header/Header'; import { NotificationBars } from 'app/_components/Layout/NotificationBars/NotificationBars'; import Providers from 'components/providers/Providers'; import { Metadata } from 'next'; import { headers } from 'next/headers'; import 'nprogress/nprogress.css'; import 'styles/theme.css'; type MetadataProps = { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; export const generateMetadata = async (props: MetadataProps): Promise<Metadata> => { // TODO: Dynamic metadata generation based on the route (refactor `useSeo`) return { title: 'Shopsys Platform App Router', description: 'Shopsys Platform App Router', }; }; type RootLayoutProps = { children: React.ReactNode; breadcrumbs: React.ReactNode; }; const RootLayout = async ({ children, breadcrumbs }: RootLayoutProps) => { const pathname = (await headers()).get('x-pathname') ?? '/'; const [consentUpdatePageUrl] = await getInternationalizedStaticUrls(['/user-consent']); const isConsentUpdatePage = consentUpdatePageUrl === pathname; return ( <Providers> <NotificationBars /> <div className="flex min-h-dvh flex-col"> <Header /> {breadcrumbs} <main className="mt-4 mb-10 flex flex-1 flex-col gap-4">{children}</main> <Footer /> <DeferredUserConsent isConsentUpdatePage={isConsentUpdatePage} /> </div> </Providers> ); }; export default RootLayout; ``` page.tsx (homepage) ``` import { Container } from './_components/Layout/Container/Container'; import { getPromotedCategoriesQuery } from './_queries/getPromotedCategoriesQuery'; import { HomepageMetadataJsonLd } from 'app/_components/Basic/Head/HomepageMetadataJsonLd'; import { BlogPreview } from 'app/_components/Blocks/BlogPreview/BlogPreview'; import { PromotedCategories } from 'app/_components/Blocks/Categories/PromotedCategories'; import { LastVisitedProducts } from 'app/_components/Blocks/Product/LastVisitedProducts/LastVisitedProducts'; import { PromotedProducts } from 'app/_components/Blocks/Product/PromotedProducts/PromotedProducts'; import { RecommendedProducts } from 'app/_components/Blocks/Product/RecommendedProducts/RecommendedProducts'; import { UpsList } from 'app/_components/Blocks/UpsList/UpsList'; import { getDomainConfig } from 'app/_utils/getDomainConfig'; import { Banners } from 'components/Blocks/Banners/Banners'; import { TypeRecommendationType } from 'graphql/types'; import { headers } from 'next/headers'; const HomePage = async () => { // const gtmStaticPageViewEvent = useGtmStaticPageViewEvent(GtmPageType.homepage); // useGtmPageViewEvent(gtmStaticPageViewEvent); const domainConfig = getDomainConfig((await headers()).get('host')!); const promotedCategoriesData = await getPromotedCategoriesQuery(); return ( <> <HomepageMetadataJsonLd url={domainConfig.url} /> <Container gap="large"> <Banners /> <UpsList /> <PromotedCategories promotedCategoriesData={promotedCategoriesData} /> <RecommendedProducts recommendationType={TypeRecommendationType.Personalized} /> <PromotedProducts /> <BlogPreview /> <LastVisitedProducts /> </Container> </> ); }; export default HomePage; ``` loading.tsx ``` import { SkeletonPageHome } from 'components/Blocks/Skeleton/SkeletonPageHome'; const RootLoading = () => { return <SkeletonPageHome />; }; export default RootLoading; ``` next config ``` /* eslint-disable @typescript-eslint/no-require-imports */ const { withSentryConfig } = require('@sentry/nextjs'); const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); /** @type {import('next').NextConfig} */ const nextConfig = { experimental: { scrollRestoration: true, middlewarePrefetch: 'strict', instrumentationHook: true, }, reactStrictMode: true, assetPrefix: process.env.CDN_DOMAIN ?? undefined, images: { loader: 'custom', deviceSizes: [480, 768, 1024, 1440], // Do not forget to update the same values in the `app/web/imageResizer.php` file imageSizes: [16, 24, 32, 48, 64, 96, 128, 256], remotePatterns: [ { hostname: process.env.DOMAIN_HOSTNAME_1, }, { hostname: process.env.DOMAIN_HOSTNAME_2, }, ], }, serverRuntimeConfig: { internalGraphqlEndpoint: `${process.env.INTERNAL_ENDPOINT}graphql/`, }, publicRuntimeConfig: { googleMapApiKey: process.env.GOOGLE_MAP_API_KEY, packeteryApiKey: process.env.PACKETERY_API_KEY, cdnDomain: process.env.CDN_DOMAIN ?? '', sentryDsn: process.env.SENTRY_DSN ?? '', sentryEnvironment: process.env.SENTRY_ENVIRONMENT ?? '', errorDebuggingLevel: process.env.ERROR_DEBUGGING_LEVEL, showSymfonyToolbar: process.env.SHOW_SYMFONY_TOOLBAR, shouldUseDefer: process.env.SHOULD_USE_DEFER === '1', userSnapApiKey: process.env.USERSNAP_PROJECT_API_KEY, userSnapEnabledDefaultValue: process.env.USERSNAP_STOREFRONT_ENABLED_BY_DEFAULT === '1', domains: [ { publicGraphqlEndpoint: process.env.PUBLIC_GRAPHQL_ENDPOINT_HOSTNAME_1, url: process.env.DOMAIN_HOSTNAME_1, defaultLocale: 'en', currencyCode: 'EUR', fallbackTimezone: 'Europe/Prague', domainId: 1, mapSetting: { latitude: 49.8175, longitude: 15.473, zoom: 7, }, gtmId: process.env.GTM_ID, isLuigisBoxActive: (process.env.LUIGIS_BOX_ENABLED_DOMAIN_IDS ?? '').split(',').includes('1'), type: 'B2C', }, { publicGraphqlEndpoint: process.env.PUBLIC_GRAPHQL_ENDPOINT_HOSTNAME_2, url: process.env.DOMAIN_HOSTNAME_2, defaultLocale: 'cs', currencyCode: 'CZK', fallbackTimezone: 'Europe/Prague', domainId: 2, mapSetting: { latitude: 48.669, longitude: 19.699, zoom: 7, }, gtmId: process.env.GTM_ID, isLuigisBoxActive: (process.env.LUIGIS_BOX_ENABLED_DOMAIN_IDS ?? '').split(',').includes('2'), type: 'B2B', }, ], }, eslint: { ignoreDuringBuilds: true, }, // FE build error fix: "ModuleNotFoundError: Module not found: Error: Can't resolve 'net' in '/app/node_modules/@node-redis/client/dist/lib/client'" // https://github.com/webpack-contrib/css-loader/issues/447#issuecomment-761853289 webpack: (config, { isServer }) => { config.resolve.fallback = { child_process: false, fs: false, util: false, http: false, https: false, tls: false, net: false, crypto: false, path: false, os: false, stream: false, zlib: false, }; if (!isServer) { config.resolve.alias.redis = false; } config.ignoreWarnings = [ ...(config.ignoreWarnings || []), { // Ignore warnings for dynamic requires in @opentelemetry/instrumentation module: /@opentelemetry\/instrumentation/, message: /Critical dependency: the request of a dependency is an expression/, }, { // Sentry itself might have dynamic requires module: /@sentry\/nextjs/, message: /Critical dependency: the request of a dependency is an expression/, }, { module: /@sentry\/node/, message: /Critical dependency: the request of a dependency is an expression/, }, ]; return config; }, }; const SentryWebpackPluginOptions = { authToken: process.env.SENTRY_AUTH_TOKEN, disableServerWebpackPlugin: process.env.APP_ENV === 'development', disableClientWebpackPlugin: process.env.APP_ENV === 'development', hideSourceMaps: true, sourcemaps: { deleteSourcemapsAfterUpload: true, }, errorHandler: (err, _invokeErr, compilation) => { compilation.warnings.push('Sentry CLI Plugin: ' + err.message); }, }; module.exports = withBundleAnalyzer(withSentryConfig(nextConfig, SentryWebpackPluginOptions)); ``` Providers.tsx ``` import { AppConfigProvider } from './AppConfigProvider'; import { AuthInfo } from './AuthInfo'; import { AuthProvider } from './AuthProvider'; import { AuthorizationProvider } from './AuthorizationProvider'; import BroadcastChannelProvider from './BroadcastChannelProvider'; import { CookiesStoreProvider } from './CookiesStoreProvider'; import { CookiesStoreSync } from './CookiesStoreSync'; import { DomainConfigProvider } from './DomainConfigProvider'; import { ProductListProvider } from './ProductListProvider'; import ToastifyProvider from './ToastifyProvider'; import { TranslationProvider } from './TranslationProvider'; import { STATIC_REWRITE_PATHS } from 'app/_config/staticRewritePaths'; import { getCurrentCustomerData } from 'app/_queries/getCurrentCustomerData'; import { getCurrentCustomerUserRoles } from 'app/_queries/getCurrentCustomerUserRoles'; import { getSettingsQuery } from 'app/_queries/getSettingsQuery'; import { getCookieStoreStateFromServer } from 'app/_utils/getCookieStoreStateFromServer'; import { getDomainConfig } from 'app/_utils/getDomainConfig'; import { getInitialProductListState } from 'app/_utils/getInitalProductListState'; import { Portal } from 'components/Basic/Portal/Portal'; import { headers } from 'next/headers'; import { getDictionary } from 'utils/getDictionary'; type ProvidersProps = { children: React.ReactNode; }; export default async function Providers({ children }: ProvidersProps) { const cookieStoreStateFromServer = await getCookieStoreStateFromServer(); const domainConfig = getDomainConfig((await headers()).get('host')!); const { defaultLocale: lang } = domainConfig; const dictionary = await getDictionary(lang); const [user, settingsData, initialState] = await Promise.allSettled([ getCurrentCustomerData(), getSettingsQuery(), getInitialProductListState(), ]); const customerUserRoles = await getCurrentCustomerUserRoles(); if (settingsData.status === 'rejected' || !settingsData.value.data?.settings) { throw new Error('Failed to fetch settings'); } return ( <CookiesStoreProvider cookieStoreStateFromServer={cookieStoreStateFromServer}> <DomainConfigProvider domainConfig={domainConfig}> <AppConfigProvider domainConfig={domainConfig} settings={settingsData.value.data.settings} staticRewritePaths={STATIC_REWRITE_PATHS[domainConfig.url]} > <TranslationProvider dictionary={dictionary} lang={lang}> <AuthProvider user={user.status === 'fulfilled' ? user.value : undefined}> <AuthorizationProvider customerUserRoles={customerUserRoles}> <ProductListProvider initialState={initialState.status === 'fulfilled' ? initialState.value : {}} > <html lang={lang}> {/* <head> <script async src="https://unpkg.com/react-scan/dist/auto.global.js" /> </head> */} {/* suppressHydrationWarning for ignoring grammarly extension */} <body suppressHydrationWarning> <ToastifyProvider> <AuthInfo isUserLoggedIn={user.status === 'fulfilled' && !!user.value} /> <CookiesStoreSync /> <BroadcastChannelProvider /> {children} <Portal /> </ToastifyProvider> </body> </html> </ProductListProvider> </AuthorizationProvider> </AuthProvider> </TranslationProvider> </AppConfigProvider> </DomainConfigProvider> </CookiesStoreProvider> ); } ``` quite enough time is spent in the const [user, settingsData, initialState] = await Promise.allSettled([ getCurrentCustomerData(), getSettingsQuery(), getInitialProductListState(), ]); i thought the loading.tsx is there to prevent waiting for this await
I found the issue! The problem is that your data fetching in Providers.tsx
is blocking the entire layout render, which prevents the streaming benefits you expected from App Router.
Your loading.tsx
file only covers the page content (children
), but your heavy data fetching is happening in the root layout's Providers component. This means:
Promise.allSettled
in ProvidersHere's how to fix this:
// Remove data fetching from layout - keep it minimal
const RootLayout = async ({ children, breadcrumbs }: RootLayoutProps) => {
const pathname = (await headers()).get('x-pathname') ?? '/';
const [consentUpdatePageUrl] = await getInternationalizedStaticUrls(['/user-consent']);
const isConsentUpdatePage = consentUpdatePageUrl === pathname;
return (
<MinimalProviders> {/* Only essential, non-async providers */}
<NotificationBars />
<div className="flex min-h-dvh flex-col">
<Header />
{breadcrumbs}
<main className="mt-4 mb-10 flex flex-1 flex-col gap-4">{children}</main>
<Footer />
<DeferredUserConsent isConsentUpdatePage={isConsentUpdatePage} />
</div>
</MinimalProviders>
);
};
// Only synchronous providers that don't require server data
export default function MinimalProviders({ children }: { children: React.ReactNode }) {
const domainConfig = getDomainConfig(headers().get('host')!);
return (
<CookiesStoreProvider cookieStoreStateFromServer={cookieStoreStateFromServer}>
<DomainConfigProvider domainConfig={domainConfig}>
<html lang={domainConfig.defaultLocale}>
<body suppressHydrationWarning>
<ToastifyProvider>
{children}
</ToastifyProvider>
</body>
</html>
</DomainConfigProvider>
</CookiesStoreProvider>
);
}
const RootLayout = async ({ children, breadcrumbs }: RootLayoutProps) => {
const pathname = (await headers()).get('x-pathname') ?? '/';
const [consentUpdatePageUrl] = await getInternationalizedStaticUrls(['/user-consent']);
const isConsentUpdatePage = consentUpdatePageUrl === pathname;
return (
<MinimalProviders>
<NotificationBars />
<div className="flex min-h-dvh flex-col">
<Header />
{breadcrumbs}
<main className="mt-4 mb-10 flex flex-1 flex-col gap-4">
<Suspense fallback={<SkeletonPageHome />}>
<DataProviders>
{children}
</DataProviders>
</Suspense>
</main>
<Footer />
<DeferredUserConsent isConsentUpdatePage={isConsentUpdatePage} />
</div>
</MinimalProviders>
);
};
// Move your heavy data fetching here, wrapped in Suspense
export default async function DataProviders({ children }: { children: React.ReactNode }) {
const [user, settingsData, initialState] = await Promise.allSettled([
getCurrentCustomerData(),
getSettingsQuery(),
getInitialProductListState(),
]);
const customerUserRoles = await getCurrentCustomerUserRoles();
if (settingsData.status === 'rejected' || !settingsData.value.data?.settings) {
throw new Error('Failed to fetch settings');
}
return (
<AppConfigProvider settings={settingsData.value.data.settings}>
<AuthProvider user={user.status === 'fulfilled' ? user.value : undefined}>
<AuthorizationProvider customerUserRoles={customerUserRoles}>
<ProductListProvider initialState={initialState.status === 'fulfilled' ? initialState.value : {}}>
{children}
</ProductListProvider>
</AuthorizationProvider>
</AuthProvider>
</AppConfigProvider>
);
}
loading.tsx
actually displaysThe key insight: Layouts should be fast and minimal. Move heavy data fetching to the page level or wrap it in Suspense boundaries 1.
i am trying to simulate this basic layout refactoring logic, i need a small playground, i create this very simplified version, are the basic concepts ok? will the layout get streamed from the server immediatelly and the user will see loading layout...
as soon as possible?
import "./global.css";
import SearchResult from "@/app/search-result";
import { Suspense } from "react";
async function searchAction(keyword: string) {
"use server";
return <SearchResult keyword={keyword} result={keyword} />;
}
const wait = (timeout: number) => {
return new Promise((resolve) => {
setTimeout(resolve, timeout);
});
};
const DummyAsyncComponent = async () => {
await wait(5000);
return <div>Content loaded after 5 seconds!</div>;
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
autocomplete: React.ReactNode;
}) {
return (
<html>
<body>
<h1 className="w-full text-center bg-blue-300 p-10">
Layout Header: {Math.random()}
<p>content1</p>
<p>content2</p>
</h1>
<Suspense fallback="loading layout...">
<DummyAsyncComponent />
</Suspense>
{children}
</body>
</html>
);
}