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
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
EcoEmbedfrom@ecopages/react/eco-embedwhen 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
| Symptom | Likely cause | Fix |
|---|---|---|
No QueryClient set after SPA navigation | TanStack Query is not shared by the Page and Layout graphs | Add @tanstack/react-query to runtimeModules and rebuild vendors |
| Duplicate React context / Tone init logs | Provider package is bundled through multiple client entrypoints | Import through the package root and register that root in runtimeModules |
Store updates / reaction / observer no-op | Duplicate singleton (for example MobX) — a library inlines one copy and app code loads another | Peer the singleton in the library; register the library and singleton specifiers in runtimeModules |
| Stale bootstrap script hash in dev | Rendered HTML cached before runtime vendors changed | Rebuild or touch a route file; development HTML cache keys include browser-runtime generation |
MISSING_EXPORT for names like BasePlugin | Vendor listed CJS enumerable keys that the ESM file does not export | Register the package root only; bundle subpath plugins separately |
Calling require for "/assets/vendors/…" | A CJS vendor graph require()d another runtime module after URL rewrite | Prefer packages with an ESM/module entry; register sibling singletons as separate runtimeModules entries |
Related guides
- React Router — SPA navigation and layout persistence
- Creating Layouts — nested layout stacks and dependencies
- Dev Toolbar — inspect page browser graph entries and vendor URLs