Expressive MVC
Guides

Server Rendering and Next.js

Client components, request safety, and what runs where

Expressive components are client components. They render fine on the server - renderToString, and the SSR pass Next.js gives every client component - without touching the DOM. What they are not is server-resident state: nothing here runs inside React Server Components themselves.


'use client'

In an App Router project, a file that defines or uses a Model class is client code. @expressive/react registers the React host as an import side effect, and it must be loaded in the client bundle - a module evaluates once, so importing it beside your classes is harmless and keeps every client entry covered:

'use client';

import '@expressive/react';
import { Component } from '@expressive/mvc';

export class Sidebar extends Component {
  open = false;

  render() {
    const { open } = this;

    return <aside data-open={open} onClick={() => (this.open = !open)} />;
  }
}

Server components compose these normally - fetch on the server, pass serializable props down, and let the instance take over from there. As with any client component, give the client the same inputs the server rendered with, and hydration lines up.


What runs where

Server renderClient
constructor, field initializers✓✓
new()✓✓
render()✓✓
effects declared in new()never✓
on({ ready }), await state.get(() => {})never for a host-owned instance✓
new()'s returned teardownwhen the request ends✓

The last three rows are the rules that matter. There is no unmount on the server, so a resource opened in the new() body - a socket, a subscription, a file handle - stays open until the request ends, which by default means garbage collection. An effect declared in new() waits for the owning component to commit, which never happens on the server, so that is where such work belongs.


Four rules for request safety

Request state goes in a <Component for>. Each render builds its own context, so provided instances are isolated - one request never sees another's.

An instance held in Context.root is process-wide - and on the server, that means shared across requests. Root suits process state: config, feature flags, a warmed cache. Keep per-request data out of it; provide it with <Component for> instead.

Resources belong in an effect or the framework's request scope, never the new() body. An effect never fires on the server, so client resources stay client-side for free. Anything the server itself must open and close belongs to the request handler, which actually has a close.

End a request by popping its root's context. Grab the root with is and pop the context its host pushed. Everything rendered beneath it is destroyed at once - new() teardowns run, children first, and a pending await state.get(() => {}) rejects. Skip it and the same happens whenever the garbage collector gets there.

let app!: App;
const html = renderToString(<App is={(a) => void (app = a)} />);
Context.get(app)!.pop();

A root that isn't a Component - a function component, say - wraps in a bare <Component is={…}> to get an instance to pop.


The Router on the server

@expressive/router targets client-routed apps. Each render's Router is its own, so paths never bleed between requests. To render a specific request's path, provide a Router per request:

<Component for={Router} path={request.path}>
  <App />
</Component>

In a Next.js app the framework owns the URL regardless.


Next

  • Concurrent React - consistency and transition priority on React 18+.
  • Context - providing, scoping, and resolution.

On this page