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 { Model } from '@expressive/mvc';

class Panel extends Model {
  open = false;
}

export function Sidebar() {
  const { open, is: panel } = Panel.use();
  return <aside data-open={open} onClick={() => (panel.open = !panel.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()✓✓
class use() method✓✓
mount()never✓
new()'s returned teardownnever✓

The last two rows are the rules that matter. There is no unmount on the server, so a resource opened in new() - a socket, a subscription, a file handle - leaks there. mount() is the client-only hook for exactly that work, and it is where effects belong.


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

A static global is process-wide - and on the server, that means shared across requests. Globals are trusted mutable process state: config, feature flags, a warmed cache. Keep per-request data out of them; provide it with <Component for> instead.

Resources belong in mount() or the framework's request scope, never new(). mount() 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.


The Router on the server

@expressive/router targets client-routed apps. The default Router is a client-only global for the same reason as the rules above: on the server it resolves per render, 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