SSR & Hydration
The dominant data pattern across all apps. Server Components fetch data and pass it as initialData to client wrappers. SWR then takes over for subsequent interactions.
For generated API clients and SWR hooks see Data Layer. For Context architecture see State Management.
The initialData Hydration Pattern
Step 1: Server Component Fetches
// app/[locale]/(pages)/catalog/page.tsx — Server Component
import { createCatalogPublicClient } from '@lib/api/server';
import { getCatalogPublicVehiclesList } from '@api/generated/catalog_public/_utils/fetchers';
export default async function CatalogPage({ searchParams }) {
const client = createCatalogPublicClient();
const [vehicles, brands] = await Promise.all([
getCatalogPublicVehiclesList({ page: 1, page_size: 20 }, client).catch(() => null),
getCatalogPublicBrandsList(undefined, client).catch(() => []),
]);
return (
<CatalogClientWrapper
initialVehicles={vehicles}
initialBrands={brands}
/>
);
}Use Promise.all for parallel fetches and .catch(() => null) for graceful fallback when individual fetches fail.
Step 2: Client Component Hydrates
// CatalogClientWrapper.tsx — 'use client'
'use client';
export function CatalogClientWrapper({ initialVehicles, initialBrands }) {
return (
<CatalogProvider>
<FiltersProvider>
<VehiclesProvider initialData={initialVehicles}>
<CatalogLayout />
</VehiclesProvider>
</FiltersProvider>
</CatalogProvider>
);
}Step 3: SWR Takes Over
// VehiclesContext.tsx
const { data: swrData, isLoading } = useCatalogPublicVehiclesList(apiParams, client);
// Prefer SWR data over SSR initialData
const vehicles = swrData ?? initialData;SWR revalidates in the background. On first render the SSR data is shown immediately — no loading spinner.
force-dynamic for Auth-Dependent Pages
Pages that depend on authenticated state or real-time data disable static generation:
export const dynamic = 'force-dynamic';Used in: dashboards, project editors, all private routes.
Client-Only Rendering
For highly interactive pages (video editors, complex canvas), SSR is skipped entirely with a mounted-state gate:
// ProjectEditorView.tsx
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
if (!mounted) return <EditorLoader />;
return <EditorLayout />;Alternatively, use Next.js dynamic import with ssr: false:
const EditorView = dynamic(() => import('./EditorView'), { ssr: false });Static Generation for Locale Segments
Only locale parameters are statically generated — content pages use SSR:
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}Metadata Generation
Server Components handle Next.js metadata via generateMetadata:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const t = await getTranslations('catalog');
return generateAppMetadata({
title: t('meta.title'),
description: t('meta.description'),
});
}The generateAppMetadata helper from @core/metadata adds OG images, canonical URLs, and other defaults.
Data Shape Adaptation
When SSR-fetched data has a different shape than what components expect, adapt with useMemo:
// SavedView.tsx — adapt VehicleDetail to VehicleList shape
const listItem = useMemo(() => ({
...data,
main_photo_url: data.photos[0]?.url ?? null,
photos_count: data.photos.length,
source: { id: data.source.id, code: data.source.code, name: data.source.name },
}), [data]);Rules
- Server Components fetch data and pass it as
initialDataprops — never fetch data inside client components directly - Use
Promise.allwith.catch(() => null)for parallel SSR fetches with graceful fallback - SWR takes over after hydration — prefer
swrData ?? initialDatato show SSR data immediately - Set
export const dynamic = 'force-dynamic'on auth-dependent and real-time pages - Use
dynamic(() => import(...), { ssr: false })or a mounted-state gate for client-only editors - Data shape mismatches between server and client are resolved with
useMemoin the view component