React Integration

The @ecopages/react package introduces first-class integration with React version 19, enabling developers to build dynamic components using the latest React features within the Ecopages platform.

Installation

npm install @ecopages/react

Also install React's required peer packages in your app: react and react-dom. In TypeScript projects, install @types/react and @types/react-dom as well.

Usage

To enable React support, add the reactPlugin to your Ecopages configuration.

import { defineConfig } from '@ecopages/core/config';
import { reactPlugin } from '@ecopages/react';
 
export default defineConfig({
	rootDir: import.meta.dirname,
	baseUrl: process.env.ECOPAGES_BASE_URL ?? 'http://localhost:3000',
	integrations: [reactPlugin()],
});

Creating Components

Standard (eco.component)

The standard way to create components in Ecopages is via the eco.component factory. This is ideal for managing dependencies (scripts, stylesheets) and enabling advanced features like lazy loading.

import { useState } from 'react';
import { eco } from '@ecopages/core';
 
export const Counter = eco.component({
	dependencies: {
		stylesheets: ['./counter.css'],
	},
	render: () => {
		const [count, setCount] = useState(0);
 
		return (
			<div className="counter">
				<p>Count: {count}</p>
				<button onClick={() => setCount((prev) => prev - 1)}>-</button>
				<button onClick={() => setCount((prev) => prev + 1)}>+</button>
			</div>
		);
	},
});

Plain React Components

You can also write standard React components directly when they only need React runtime behavior and do not need Ecopages-managed dependency declarations.

This is especially useful if you are using Tailwind CSS, as you do not need to attach specific stylesheets or declare dependencies.

import { useState } from 'react';
 
export function SimpleCounter() {
	const [count, setCount] = useState(0);
	return (
		<button
			className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
			onClick={() => setCount(count + 1)}
		>
			Count: {count}
		</button>
	);
}

Using in Pages

Import the component and assign the layout on eco.page(). Ecopages discovers local component imports automatically.

import { Counter } from '@/components/counter';
import { BaseLayout } from '@/layouts/base-layout';
import { eco } from '@ecopages/core';
 
export default eco.page({
	layout: BaseLayout,
	render: () => (
		<>
			<h1>Welcome to React on Ecopages</h1>
			<Counter />
		</>
	),
});

Hydratable React components stamp island host attributes on their SSR root for devtools and client hydration.

Utilities

dynamic

Use the dynamic utility to lazy-load components or render them only on the client.

import { dynamic } from '@ecopages/react/utils/dynamic';
 
const HeavyComponent = dynamic(() => import('@/components/heavy-chart'), {
	ssr: false,
});

With ssr: true, dynamic returns a React.lazy() component, and the server renders its content into the HTML.

Suspense on the server

Server rendering waits for every <Suspense> boundary before it returns HTML, in Pages, Layouts and every other React component they render, so React.lazy() and <Suspense> render their content, not their fallback. The document is still sent in one piece, not streamed. Waiting stops after 10 seconds by default. The limit covers the whole Page render, every React tree in it included: unfinished boundaries stay in their fallback for the browser to retry, the rest of the Page is served, and one warning names the Page. A tree that is still waiting outside every boundary has no HTML to serve, so the render fails instead. React components in a Page of another Integration each get the full limit. Pass reactPlugin({ renderTimeoutMs: 5_000 }) to change the limit, a whole number of milliseconds from 1 to 2147483647. A component that throws, or an import that rejects, inside a boundary is logged on the server and that boundary's fallback is served for the browser to retry; an error outside every boundary fails the render. A boundary that renders a <link rel="stylesheet" precedence> also fails the render: declare the stylesheet as a Dependency instead.

ClientOnly

The ClientOnly component is useful for parts of the UI that should strictly render in the browser (e.g., accessing window or localStorage).

import { ClientOnly } from '@ecopages/react/utils/client-only';
 
<ClientOnly fallback={<Spinner />}>
	<BrowserSpecificFeature />
</ClientOnly>;

Key Features

  • React 19: Server rendering and hydration for standard React 19 components and hooks.
  • Fast Refresh: Built-in Hot Module Replacement (HMR) for instant feedback.
  • MDX: Built-in React MDX support through reactPlugin({ mdx: {} }).
  • Mixed Rendering: React can own routes directly or resolve nested React boundaries inside non-React shells. Use EcoEmbed from @ecopages/react/eco-embed when a React page composes foreign-integration shells or passes children across integration boundaries.

Mixed rendering and EcoEmbed

When a React route nests shells owned by another integration, import EcoEmbed from @ecopages/react/eco-embed. It wraps eco.embed() so the active foreign-child runtime can hand the subtree to the owning renderer.

/** @jsxImportSource react */
import { eco } from '@ecopages/core';
import { EcoEmbed } from '@ecopages/react/eco-embed';
import { KitaShell } from '@/components/kita-shell';
 
export default eco.page({
	dependencies: {
		components: [KitaShell],
	},
	render: () => (
		<EcoEmbed component={KitaShell} props={{ id: 'kita-shell' }}>
			React-owned leaf
		</EcoEmbed>
	),
});

Plain opaque objects fail fast at core foreign-subtree queue boundaries; they are not coerced with String(object). See Ecopages JSX — Mixed Rendering for accepted child shapes and the queue contract.

MDX Support

The React plugin includes optional MDX support. When configured, you can author .mdx pages alongside .tsx pages with unified client-side routing, hydration, and HMR.

import { defineConfig } from '@ecopages/core/config';
import { reactPlugin } from '@ecopages/react';
import { ecoRouter } from '@ecopages/react-router';
 
export default defineConfig({
	rootDir: import.meta.dirname,
	baseUrl: process.env.ECOPAGES_BASE_URL ?? 'http://localhost:3000',
	integrations: [
		reactPlugin({
			router: ecoRouter(),
			mdx: {
				remarkPlugins: [],
				rehypePlugins: [],
			},
		}),
	],
});

This is the recommended approach when using a client-side router, as it ensures seamless navigation between TSX and MDX pages.

Shared runtime vendors

With ecoRouter(), Ecopages can keep outer Layout tiers mounted across client navigation (persistLayouts defaults to true). Each Page chunk still loads separately, so a package used by both a Layout and a Page may otherwise load as separate module instances. That breaks libraries with shared state, such as React context and TanStack Query.

Register packages that need one browser module instance in runtimeModules. Ecopages builds each entry once into /assets/vendors/*.js; Page and Layout code then import the same vendor URL. React, React DOM, and the configured router are already shared. Ecopages does not infer vendor packages from Layout files.

import { defineConfig } from '@ecopages/core/config';
import { reactPlugin } from '@ecopages/react';
import { ecoRouter } from '@ecopages/react-router';
 
export default defineConfig({
	integrations: [
		reactPlugin({
			router: ecoRouter(),
			runtimeModules: ['@tanstack/react-query'],
		}),
	],
});

Register the specifier that app code imports. Package subpaths are separate modules, so list each subpath used by a Page or Layout. runtimeModules also accepts entries with a custom vendor filename:

Migration from layout discovery: remove runtimeProvider from layout options. Add each shared package the scanner previously supplied to runtimeModules using the exact specifier imported by app code.

reactPlugin({
	router: ecoRouter(),
	runtimeModules: [
		'@tanstack/react-query',
		{ specifier: '@acme/ui', outputName: 'acme-ui' },
	],
});

A library that depends on a singleton such as MobX or TanStack Query should declare it as a peerDependency and keep it external in its own build. Register both the library package and singleton in the app's runtimeModules; matching imports between their vendors are rewritten to the shared vendor URL. Remove any existing externals fields from runtime module entries and register each non-framework shared dependency as a separate entry. React, React DOM, and the configured router are shared automatically.

In ecopages dev, Page modules are served from /assets/__eco_dev__/ and import shared packages from /assets/vendors/*. Use the dev toolbar Deps panel to inspect vendor URLs loaded by the active Page.

Vendor troubleshooting

SymptomLikely causeFix
No QueryClient set after SPA navigationTanStack Query is not shared by the Page and Layout graphsAdd @tanstack/react-query to runtimeModules and rebuild vendors
Duplicate React context / Tone init logsProvider package is bundled through multiple client entrypointsImport through the package root and register that root in runtimeModules
Store updates / reaction / observer no-opDuplicate singleton (for example MobX) — a library inlines one copy and app code loads anotherPeer the singleton in the library; register the library and singleton specifiers in runtimeModules
Stale bootstrap script hash in devRendered HTML cached before runtime vendors changedRebuild or touch a route file; development HTML cache keys include browser-runtime generation
MISSING_EXPORT for names like BasePluginVendor listed CJS enumerable keys that the ESM file does not exportRegister the package root only; bundle subpath plugins separately
Calling require for "/assets/vendors/…"A CJS vendor graph require()d another runtime module after URL rewritePrefer packages with an ESM/module entry; register sibling singletons as separate runtimeModules entries