Skip to main content

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.

Requires a recent SDK

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

  1. preloadComponents() — the browser still fetches the config, just earlier and off the render path. One line of code, and it solves most cases.
  2. 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.
  3. 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 second preloadComponents() 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 CloudRepository per 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

Import 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.

Seed during render, not in a 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

The host package is browser-only

@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: false means 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):

MetricClient-sideSSR-hydrated
TTFB39 ms39 mssame1
Config acquisition787 ms0 msSSR faster by 787 ms
Mount → rendered887 ms30 msSSR faster by 857 ms
Navigation → rendered1,193 ms356 msSSR faster by 837 ms
Browser → cloud.myop.dev1 request0 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.

Experimental — talk to us first

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 Discord

Other 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