🧩 Hybrid Components in Next.js 15 (Mosaik Style)
Why This Pattern Exists
With Next.js 13+ (including 15), we’re now working with the App Router, which introduces a strict separation between:
- Server Components — rendered only on the server, no access to
useState
,useEffect
, or other browser-only features. - Client Components — must be explicitly marked with
"use client"
and are not rendered at all on the server.
This creates a critical challenge:
⚠️ As soon as a component requires interactivity or context, it becomes a
"use client"
component — and that means it won’t be server-rendered.
🤯 Why That’s a Problem
Let’s take the Sidebar as an example:
- It needs client state (expanded/collapsed, responsive behavior) → so it’s a
"use client"
component. - But it also contains navigation and layout-critical content — we absolutely want this rendered at build time for SEO, performance, and layout stability.
Next.js won’t render "use client"
components during static generation → your entire Sidebar becomes a blank <div>
in the HTML. That's catastrophic for UX and CLS (Cumulative Layout Shift).
✅ Our Solution: Hybrid Components
We built a hybrid rendering pattern that gives us the best of both worlds:
- Full SSR output from the server (so you see your navigation instantly, even in static builds).
- Full client hydration with access to context, hooks, and interactivity.
- No flicker, no mismatch, no tradeoffs.
🧠 How It Works
Step 1: The Server Knows How to Render the Component
Thanks to our theme system, the server can just render the same themed component that would normally be rendered on the client.
It passes in default props (e.g. expanded: true
) since it has no client state.
// SidebarContent.tsx (server)
export const SidebarContent = createHybridServerWrapper(
"SidebarContent",
SidebarContentClient
);
This gives us actual HTML on the server — perfect for SEO and avoiding layout shifts.
Step 2: The Client Hydrates and "Hot-Swaps" Logic
Once the page is interactive, the client version of the component takes over.
// SidebarContentClient.tsx (client)
"use client";
export default function SidebarContentClient({ Component, ...rest }) {
const [hydrated, setHydrated] = useState(false);
const SidebarContent = useThemedComponent("SidebarContent");
const { state } = useAppState();
useEffect(() => setHydrated(true), []);
if (!hydrated || !SidebarContent) {
return Component; // same markup, no mismatch
}
return <SidebarContent {...rest} state={state} />;
}
Until hydration completes, we render the exact same output as the server did, preventing React hydration errors and flicker.
🔐 Design Constraints Imposed by Next.js
❌ You cannot render a "use client"
component during SSG.
That means any interactive component that isn't hybrid won’t show up in the HTML at all.
✅ You can render a server component and hydrate later — if you avoid mismatches.
That means you need a hybrid wrapper that knows how to:
- Render fallback server markup before hydration.
- Replace it safely with the interactive version after hydration.
This is what createHybridServerWrapper
and createHydratableComponent
do.
🧱 Directory Convention
@/blocks/hybrid/
├── ComponentName.tsx // Server wrapper (SSR logic)
├── ComponentNameClient.tsx // Client component (hydration logic)
🔧 Helper: createHybridServerWrapper
export function createHybridServerWrapper<P>(
componentName: string,
ClientComponent: React.ComponentType<P & { Component: React.ReactElement }>
) {
return async function ServerWrapper(props: P) {
const { getThemedComponent } = await import("@/lib/server/getThemedComponent");
const Themed = await getThemedComponent(componentName);
const serverMarkup = <Themed {...props} />;
return <ClientComponent {...props} Component={serverMarkup} />;
};
}
🔧 Helper: createHydratableComponent
"use client";
import React, { useEffect, useState, ReactElement } from "react";
type Props<ClientProps> = ClientProps & { Component: ReactElement };
export function createHydratableComponent<ClientProps>(
ClientComponent: React.ComponentType<ClientProps>
) {
return function HydratableComponent(props: Props<ClientProps>) {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
if (!hydrated) {
return props.Component;
}
const { Component, ...clientProps } = props;
return <ClientComponent {...(clientProps as ClientProps)} />;
};
}
⚠️ Why This Pattern Matters
Goal | Problem | Solution |
---|---|---|
SSR/SSG Rendering | "use client" components aren't rendered statically |
Use hybrid server wrapper with themed fallback |
Client Interactivity | Server components can’t use state/context | Hydrate after mount with full client logic |
No Flicker / CLS | Mismatches cause layout shifts and hydration errors | Render identical output before/after hydration |
🎯 Slot System + Hybrid = Full Server Composition
In Mosaik, we rely heavily on slot-based UI composition — layout shells expose slot points, and feature blocks inject content into them.
To preserve server-side rendering, we must be able to evaluate slot content before rendering.
This is tricky because slots are often defined via dynamic logic.
So:
- We define
getDesktopSlots()
functions that return a serializable object mapping slot names to components. - Layout blocks (like
Desktop
) can then use this at build time to server-render everything in place. - These slots may contain hybrid components — allowing hydration later without flicker.
This pattern lets us compose flexible slot-based UIs on the server — while preserving interactive logic on the client via hybrid components.
💥 Summary
This hybrid approach is necessary given how React and Next.js 15 split rendering responsibilities:
- SSR only works for server components — but many real-world UI elements (like sidebars, tabs, drawers) need interactivity.
- Without hybrid components, you’re forced to choose between no server rendering or no client state — not acceptable.
- With our approach, we render themed components on the server with fallbacks, then hydrate the real client logic cleanly after mount.
🚀 This gives us blazing fast, fully interactive, composable UIs — with no flicker, no layout shift, and full SSR.