SSR & Server-Side Config
By default, <MyopComponent /> resolves its config in the browser: the
component mounts, the SDK fetches the config from cloud.myop.dev, and only
then can the component render. On a first visit that round trip sits directly
in front of your content.
This page shows how to move that fetch to your server, cache it, and ship the resolved config inside the HTML — so the browser renders the component with no request to Myop cloud at all.
Both paths use the same public @myop/react API. Nothing here is a workaround
or a private hook.
hydrateComponents() is a recent addition. If your editor cannot find it, your
@myop/sdk and framework package predate it — upgrade both together, since the
host packages bundle their own copy of the SDK.
Three ways to get the component on screen sooner
preloadComponents()— the browser still fetches the config, just earlier and off the render path. One line of code, and it solves most cases.- The SSR fetch — your server resolves the config and embeds it in the HTML, so the browser renders the component with no request to Myop cloud. This is what the rest of this page builds.
- Full SSR — the server returns the component already rendered as part of the HTML. Experimental.
Try preloadComponents() first
Most "the component appears late" problems do not need SSR. The SDK already ships a one-line fix that moves the fetch off the render path:
"use client";
import { preloadComponents } from "@myop/react";
// as early as you can — a top-level client provider, a layout effect
await preloadComponents([COMPONENT_ID]);
Note the "use client": @myop/react cannot be imported from a Server
Component, or from anything Node evaluates. See
Keep @myop/react out of the server bundle.
The fetch now overlaps with everything else your app is doing, and by the time
<MyopComponent /> mounts the config is already in the SDK's cache. This is the
documented, supported API — see Preloading.
Reach for SSR only when preloading is not enough: the component is above the fold and you cannot start early enough, your users are on slow networks where even an overlapped fetch is hundreds of milliseconds, or you want the component's config to cost the browser nothing at all.
The default path, and what it costs
<MyopComponent componentId={id} data={data} />
Internally this resolves the config through the SDK's shared cloud repository, from a mount effect. The sequence is:
navigation ─ ▶ HTML ─▶ JS ─▶ mount ─▶ fetch config ─▶ render
└── blocking ──┘
That fetch is a real cross-origin request on the user's connection. Measured on
a local Next.js app against production cloud.myop.dev, it costs ~790 ms,
and the component is not on screen until ~1.3 s after navigation.
The SSR fetch
Three moves: resolve on the server, put the config on the page, tell the SDK to read it from there.
1. Resolve the config on the server
CloudRepository from @myop/sdk/helpers runs fine in Node, and
resolveComponent() returns a plain, JSON-serialisable object (~19 KB for a
typical component). That is the whole reason this pattern works.
// lib/myop-server.ts — SERVER ONLY
import { CloudRepository } from "@myop/sdk/helpers";
const TTL_MS = 60_000;
type Entry = { createdAt: number; inflight: Promise<any> };
const cache = new Map<string, Entry>();
export async function resolveComponentConfig(componentId: string) {
const now = Date.now();
let entry = cache.get(componentId);
if (!entry || now - entry.createdAt >= TTL_MS) {
// A FRESH CloudRepository per miss, on purpose — see the note below.
const repo = new CloudRepository();
entry = { createdAt: now, inflight: repo.resolveComponent(componentId) };
cache.set(componentId, entry);
}
try {
return await entry.inflight;
} catch (err) {
cache.delete(componentId); // never cache a failure
throw err;
}
}
Three details in that cache are load-bearing:
-
The instance method, not
preloadComponents(). The module-level helpers (preloadComponents,getResolvedConfig,isPreloaded) all operate on one process-wide singleton whose cache never expires — a secondpreloadComponents()call for the same id returns in 0 ms, forever. That is the right behaviour in a browser tab and the wrong one in a long-lived server, where it would silently pin a config for the life of the process. Going through your own instance is what makes the TTL real. -
Cache the promise, not the value. N concurrent requests during a cold window then share one origin fetch instead of stampeding it. Measured: five parallel cold requests finished in ~380 ms total, not 5 × 900 ms.
-
Build a fresh
CloudRepositoryper miss. Same reason, one level down: a long-lived instance keeps its own permanent cache, so reusing it would make every "miss" a silent 0 ms hit — you would think you had a 60-second cache and actually have a forever one.
2. Put the config on the page
In the App Router, a Server Component awaiting the config and passing it to a Client Component is all it takes — Next serialises the object into the page:
// app/ssr/page.tsx
import { resolveComponentConfig } from "@/lib/myop-server";
import SsrDemo from "@/components/SsrDemo";
export const dynamic = "force-dynamic"; // observe YOUR cache, not Next's
export default async function Page() {
const config = await resolveComponentConfig(COMPONENT_ID);
return <SsrDemo config={config} />;
}
No second document, no extra API route, no <script> tag to hand-write.
3. Seed it on the client with hydrateComponents()
hydrateComponents() is the SDK's counterpart to preloadComponents(): where
preload fetches configs into the cache, hydrate seeds configs you already
hold. After seeding, resolveComponent, getResolvedConfig and isPreloaded
all answer for that component from memory:
"use client";
import { MyopComponent, hydrateComponents } from "@myop/react";
export default function MyopStage({ configs }: { configs: Record<string, any> }) {
// Seed DURING RENDER — see the warning below.
hydrateComponents(configs);
return <MyopComponent componentId={COMPONENT_ID} data={data} />;
}
That is the whole client half. Anything you did not seed still falls through to the network, so the page degrades gracefully.
Pass the same env / preview the config was resolved with —
hydrateComponents(configs, "staging", false) — or omit them, in which case the
defaults match what a plain preloadComponents([id]) would have used.
Two things that are easy to get wrong
hydrateComponents from @myop/react, not @myop/sdk/helpers@myop/react bundles its own copy of @myop/sdk. The module-level cache that
<MyopComponent /> reads lives inside that bundle, so only the helper
re-exported by @myop/react seeds the cache the component will actually look in.
Calling the one from @myop/sdk/helpers seeds a different module's cache and the
component keeps fetching over the network — with no error to tell you.
The same applies to @myop/vue, @myop/angular and @myop/react-native: import
these helpers from your framework's package.
useEffect<MyopComponent /> kicks off its load from a mount effect, and in React child
effects run before parent effects. A wrapper that seeds in useEffect always
loses that race and the component fetches anyway. Call hydrateComponents() in
the component body — seeding the same config twice is harmless.
Keep @myop/react out of the server bundle
@myop/react bundles @myop/sdk/host, which touches window at import time.
Importing it anywhere that Node evaluates — including the SSR pass of a Client
Component — throws ReferenceError: window is not defined and returns a 500.
@myop/sdk/helpers → safe on the server ✅ (this is what resolves the config)
@myop/sdk/host → throws on import ❌
@myop/react → throws on import ❌ (it bundles host)
This is not specific to SSR-hydration — it applies to any App Router page that renders a Myop component. Bundlers sometimes defer the offending module and let you get away with it, so a build can work and then break on a cache-clean rebuild.
The fix is to put every @myop/react import in one module and load it with
ssr: false:
// components/MyopStage.tsx — the ONLY file that imports @myop/react
"use client";
import { MyopComponent, hydrateComponents } from "@myop/react";
export default function MyopStage({ configs }) {
hydrateComponents(configs);
return <MyopComponent componentId={COMPONENT_ID} data={data} />;
}
// any client component
const MyopStage = dynamic(() => import("./MyopStage"), {
ssr: false,
loading: () => <Skeleton />,
});
lib/myop-server.ts keeps importing @myop/sdk/helpers directly — only the host
half is browser-only.
This also removes a hydration mismatch you would otherwise hit:
<MyopComponent /> calls isPreloaded() during render to decide whether to show
its loader, which returns false on the server and true in a hydrated browser.
With ssr: false the component never renders on the server, so the question
never arises. The component hosts an iframe — there was never anything to
server-render anyway.
Measuring it honestly
Read the browser's Resource Timing entries — this both measures the client path and proves the SSR one:
const requests = performance
.getEntriesByType("resource")
.filter((e) => e.name.startsWith("https://cloud.myop.dev") &&
e.name.includes(componentId));
// client-side path: 1 entry, requests[0].duration is the config fetch
// SSR path: 0 entries
Cross-origin entries without Timing-Allow-Origin still expose startTime and
duration, which is all you need. Pair it with performance.now() inside
onLoad for mount-to-rendered, and PerformanceNavigationTiming.responseStart
for TTFB.
Four habits that keep the numbers real:
- Turn off React strict mode while measuring. Double-mounted effects skew everything.
- Re-measure with a hard reload, never a remount. The SDK's in-memory cache will happily report a dishonest 0 ms.
- Start the clock inside the dynamically-loaded module.
ssr: falsemeans a lazy chunk fetch before the component mounts. Measure from its mount, so both modes exclude the same chunk load, and let the navigation-to-rendered number carry the full cost. - Report cache HIT and MISS separately. A blended TTFB median hides the only interesting thing about the server cache.
Results
Measured on a local Next.js production build against real cloud.myop.dev, with
a warm server cache (2 client runs, 3 SSR runs, medians):
| Metric | Client-side | SSR-hydrated | |
|---|---|---|---|
| TTFB | 39 ms | 39 ms | same1 |
| Config acquisition | 787 ms | 0 ms | SSR faster by 787 ms |
| Mount → rendered | 887 ms | 30 ms | SSR faster by 857 ms |
| Navigation → rendered | 1,193 ms | 356 ms | SSR faster by 837 ms |
Browser → cloud.myop.dev | 1 request | 0 requests |
1 TTFB ties because every SSR run above hit the 60-second cache, where the server spends ~0–1 ms resolving. On a cold miss it pays the origin fetch inside the response: ~1,005 ms TTFB versus ~56 ms on a hit, measured on the same machine.
Trade-offs
SSR moves the fetch, it does not delete it. On a cache miss, one user waits for it in TTFB instead of after paint — worse for them, better for everyone in the next 60 seconds. Whether that is a good deal depends on your traffic: at one request per minute the cache never helps, at fifty it almost always does.
The cache above is per-process and in memory. Every instance in a fleet keeps
its own, so your real miss rate scales with instance count. For anything larger
than a single box, back it with Redis or Next's unstable_cache — keep the same
promise-caching shape either way.
Staleness is now yours to manage. A 60-second TTL means a publish can take up to a minute to reach users, where the client-side path picks it up on the next page load. Shorten the TTL, or bust it from a deploy webhook, if that matters to you.
Page weight grows. Each embedded config adds its serialised size (~19 KB uncompressed for a typical component, much less over the wire with gzip) to every HTML response. Seed only the components that are actually on the page.
Full SSR
The two approaches above both ship a config: the component's markup is still produced in the browser. Full SSR goes one step further — the server returns the component already rendered as part of the HTML, so it is in the first paint.
Full SSR is experimental and not part of the public SDK. If it is what your use case needs, come tell us what you are building and we will help you get set up.
Ask us on DiscordOther frameworks
The pattern is not Next-specific. Any framework that can run Node on the server
and pass serialised data to the client can do this — Remix loaders, Nuxt's
useAsyncData with @myop/vue, Angular Universal with TransferState. The two
halves are always the same: a CloudRepository instance resolving the config on
the server, and hydrateComponents() seeding it on the client, imported from
your framework's Myop package.
@myop/react, @myop/vue, @myop/angular and @myop/react-native all export
the same eleven helpers — preloadComponents, hydrateComponents,
getResolvedConfig, getCloudRepository, isPreloaded, getPreloadedParams,
setEnvironment, setCloudRepository, setCloudRepositoryUrl,
enableLocalDev, enableSelfHosted — so the pattern ports across frameworks
unchanged.
Where to next
- Preloading —
preloadComponents(), the simpler fix that solves most cases without any of this - React Host — the full
@myop/reactguide - SDK Configuration —
enableSelfHosted()and other repository swaps built on the same seam @myop/reactreference — full type signatures- System architecture — how host, cloud, and component runtime fit together