---
title: SSR & Server-Side Config
description: "Resolve a Myop component's config on the server, embed it in the HTML, and render on the client with zero network round trips. Next.js App Router pattern with @myop/react, a 60-second server cache, and measured results."
keywords: [myop ssr, myop nextjs, server side rendering, hydrateComponents, preloadComponents, resolveComponent, CloudRepository, myop react server components, prefetch myop config]
sidebar_position: 3.6
---
# 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.

:::info 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()`](#try-preloadcomponents-first)** — 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](#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](#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:

```tsx
"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](#keep-myopreact-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](/docs/learnMyop/ReactHost#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

```tsx
<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.

```ts
// 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:

```tsx
// 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:

```tsx
"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

:::warning 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.
:::

:::warning 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

:::danger 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`:

```tsx
// 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} />;
}
```

```tsx
// 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:

```ts
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):

| Metric | Client-side | SSR-hydrated | |
|---|---:|---:|---|
| TTFB | 39 ms | 39 ms | same<sup>1</sup> |
| 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** | |

<sup>1</sup> 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](/docs/self-hosting/generic-webhooks), 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.

:::info 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.

<a href="https://myop.dev/discord" style={{display: 'inline-flex', alignItems: 'center', gap: '0.4rem', fontWeight: 600}}><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style={{flexShrink: 0}}><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg><span>Ask us on Discord</span></a>
:::

## 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

- [Preloading](/docs/learnMyop/ReactHost#preloading) — `preloadComponents()`, the
  simpler fix that solves most cases without any of this
- [React Host](/docs/learnMyop/ReactHost) — the full `@myop/react` guide
- [SDK Configuration](/docs/self-hosting/sdk-config) — `enableSelfHosted()` and other
  repository swaps built on the same seam
- [`@myop/react` reference](/docs/sdk-react) — full type signatures
- [System architecture](/docs/learnMyop/systemArchitecture) — how host, cloud, and
  component runtime fit together
