---
title: 'React'
description: 'Use React 19 components and SSR within the Ecopages rendering model.'
order: 4
---

import { RuiAlert, RuiAlertDescription, RuiAlertTitle } from '@ecopages/radiant-ui/alert';
import { CodeTabs } from '@/components/code-tabs';

# React Integration

The `@ecopages/react` package introduces first-class integration with [React](https://reactjs.org/) version 19, enabling developers to build dynamic components using the latest React features within the Ecopages platform.

## Installation

<CodeTabs
	name="react-install"
	tabs={[
		{ id: 'npm', label: 'npm', code: 'npm install @ecopages/react' },
		{ id: 'pnpm', label: 'pnpm', code: 'pnpm add @ecopages/react' },
		{ id: 'bun', label: 'bun', code: 'bun add @ecopages/react' },
	]}
	defaultSelectedKey="npm"
/>

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.

```typescript
import { ConfigBuilder } from '@ecopages/core/config-builder';
import { reactPlugin } from '@ecopages/react';

const appRoot = process.cwd();

const config = await new ConfigBuilder()
	.setRootDir(appRoot)
	.setBaseUrl(process.env.ECOPAGES_BASE_URL ?? 'http://localhost:3000')
	.setIntegrations([reactPlugin()])
	.build();

export default config;
```

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

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

```tsx
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 include it in the page. Register it in the page's `dependencies`.

```tsx
import { Counter } from '@/components/counter';
import { BaseLayout } from '@/layouts/base-layout';
import { eco } from '@ecopages/core';

export default eco.page({
	dependencies: {
		components: [BaseLayout, Counter],
	},
	render: () => {
		return (
			<BaseLayout>
				<h1>Welcome to React on Ecopages</h1>
				<Counter />
			</BaseLayout>
		);
	},
});
```

Hydratable React components stamp [island host attributes](/docs/core/island-hosts) 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.

```tsx
import { dynamic } from '@ecopages/react/utils/dynamic';

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

### `ClientOnly`

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

```tsx
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: { enabled: true } })`.
- **Mixed Rendering**: React can own routes directly or resolve nested React boundaries inside non-React shells.

## MDX Support

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

```typescript
import { ConfigBuilder } from '@ecopages/core/config-builder';
import { reactPlugin } from '@ecopages/react';
import { ecoRouter } from '@ecopages/react-router';

const appRoot = process.cwd();

const config = await new ConfigBuilder()
	.setRootDir(appRoot)
	.setBaseUrl(process.env.ECOPAGES_BASE_URL ?? 'http://localhost:3000')
	.setIntegrations([
		reactPlugin({
			router: ecoRouter(),
			mdx: {
				enabled: true,
				compilerOptions: {
					// Optional: remark/rehype plugins
				},
			},
		}),
	])
	.build();

export default config;
```

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

<RuiAlert variant="info" layout="banner" class="unstyled">
	<RuiAlertTitle>Standalone MDX</RuiAlertTitle>
	<RuiAlertDescription>
		<p>
			Use the standalone <a href="/docs/integrations/mdx">MDX Integration</a> only when MDX should compile against
			a non-React JSX runtime. React-backed MDX belongs on the React plugin even when you are not using
			client-side routing.
		</p>
	</RuiAlertDescription>
</RuiAlert>

## Shared runtime vendors

With [`ecoRouter()`](/docs/ecosystem/react-router), Ecopages keeps outer layout tiers mounted across client navigation (`persistLayouts` defaults to `true`). Each page chunk still loads separately, but provider layouts stay alive in the DOM.

That creates a **module identity** problem: if TanStack Query (or any shared provider library) is bundled into every page chunk separately, each chunk gets its own copy of the library and its own React context. Navigation then breaks with errors such as `No QueryClient set`, even though the provider component is still mounted.

The fix is **shared browser runtime vendors**: selected npm packages are built once into `/assets/vendors/*.js` and every page chunk imports the same public URL. React, React DOM, the router bundle, and auto-discovered layout runtime packages all follow this path.

In `ecopages dev`, page modules are served from `/assets/__eco_dev__/` and should import `/assets/vendors/*` for shared packages instead of inlining `node_modules`.

### When auto-discovery runs

Auto-discovery runs at plugin setup when **both** are true:

- `router` is passed to `reactPlugin()`
- The app config exposes `absolutePaths.projectDir`, `layoutsDir`, and `componentsDir`

Without `router`, only explicit `runtimeModules` entries are vendored.

### Discovery modes

| Mode | Trigger | Layout roots scanned | npm packages collected |
| --- | --- | --- | --- |
| **Runtime-provider** (recommended) | At least one layout sets `runtimeProvider: true` | Only flagged layouts | Every reachable npm package in that layout's `render` graph |
| **Provider-scoped fallback** | No layout sets `runtimeProvider: true` | All `eco.layout(` files under `layouts/` and `components/` | npm packages imported from provider/context modules only |

The fallback exists for backward compatibility. Ecopages logs a debug message when fallback mode is active. Prefer explicit `runtimeProvider: true` on provider root layouts (for example a query-client tier) and omit it from shell-only layouts.

### What gets discovered

1. Select layout entry files using the mode above.
2. From each root layout's **`render` client graph**, follow relative imports and tsconfig path aliases. Type-only imports, side-effect-only imports (for example `import "mobx"`), and `.server.ts` modules are skipped.
3. Collect npm package roots (for example `@tanstack/react-query`, not `@tanstack/react-query/devtools`).
4. Register each discovered package as a shared vendor.

**Excluded automatically:** React, React DOM, jsx runtimes, the router bundle, `@ecopages/*`, workspace packages under `@techn.es/*`, `*-devtools` packages, and packages already vendored by the React plugin.

**Not discovered:** npm packages reachable only from page UI or shell layouts outside a `runtimeProvider` graph. Register those explicitly in `runtimeModules`.

Path aliases resolve from `tsconfig.json` `compilerOptions.paths`, same as the Ecopages alias resolver plugin.

### Recommended setup

Plugin config — no manual vendor list when provider layouts are flagged:

```typescript
import { ConfigBuilder } from '@ecopages/core/config-builder';
import { reactPlugin } from '@ecopages/react';
import { ecoRouter } from '@ecopages/react-router';

const config = await new ConfigBuilder().setIntegrations([reactPlugin({ router: ecoRouter() })]).build();

export default config;
```

Provider root layout — set `runtimeProvider: true` on the tier that mounts shared client state:

```tsx
import type { ReactNode } from 'react';
import { eco } from '@ecopages/core';
import { QueryProvider } from '@/shared/query/query-provider';

export const QueryRootLayout = eco.layout<ReactNode>({
	runtimeProvider: true,
	render: ({ children }) => <QueryProvider>{children}</QueryProvider>,
});
```

Shell layouts that do not mount shared runtime state omit the flag. Stack provider roots before shell tiers in page `layout` arrays so context wraps the shell on both SSR and persisted client navigation.

### Overrides and singleton packages

Use explicit `runtimeModules` when discovery misses a package, when `router` is not enabled, or when you need custom vendor output names or `externals`:

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

Manual entries **override** auto-discovered entries for the same specifier. Discovery normalizes package subpaths to package roots; import rewrite matches **exact** specifiers registered in `runtimeModules`.

`externals` on a library vendor must already be shared vendors (React, the router bundle, or another `runtimeModules` specifier). Unmapped externals throw at plugin setup instead of emitting a bare specifier the browser cannot resolve.

Some npm packages must exist as **one browser module** across layout vendors, page chunks, and dev lazy prebundles. React context, MobX observables, Redux stores, TanStack Query clients, and audio runtimes (for example Tone.js) all break when two copies load.

Layout auto-discovery only walks `eco.layout()` render graphs. Page-level imports of a singleton that is not registered in `runtimeModules` may be lazily prebundled into a **second** vendor in dev, or inlined into page chunks in production. If a shared library vendor also bundles that singleton internally, you get duplicate instances even though both sides import the same package name.

**Library packaging:** ship singleton deps as `peerDependencies` and keep them external in the library build so `dist/` retains `import … from "mobx"` (or equivalent) instead of inlining a private copy.

**Ecopages app config:** register each singleton as its own `runtimeModules` entry and list it in `externals` on any library vendor that imports it:

```typescript
reactPlugin({
	router: ecoRouter(),
	runtimeModules: [
		{ specifier: 'mobx', outputName: 'mobx' },
		{ specifier: 'mobx-react-lite', outputName: 'mobx-react-lite' },
		{
			specifier: '@acme/store-ui',
			outputName: 'acme-store-ui',
			externals: ['mobx', 'mobx-react-lite', 'react', 'react-dom'],
		},
	],
});
```

<RuiAlert variant="warning" layout="banner" class="unstyled">
	<RuiAlertTitle>All three layers are required</RuiAlertTitle>
	<RuiAlertDescription>
		<p>
			Registering only the library vendor, or only the singleton, is not enough when the published library still
			inlines the singleton. Peer the dependency in the library, externalize it in the library build, register it
			in <code>runtimeModules</code>, and list it in <code>externals</code> on the library vendor.
		</p>
		<p>
			Rebuild vendors (and clear <code>.eco</code> / <code>dist/assets/vendors</code> in dev) after changing
			package entry points or <code>runtimeModules</code>.
		</p>
	</RuiAlertDescription>
</RuiAlert>

### Vendor troubleshooting

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `No QueryClient set` after SPA navigation | Provider library bundled per page chunk | Enable `router`; add `runtimeProvider: true` on the provider layout; rebuild vendors |
| Duplicate React context / Tone init logs | Provider package 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) | Peer the singleton in the library; add explicit `runtimeModules`; list it in `externals` on the library vendor; rebuild vendors |
| Wrong packages vendored (slow dev startup) | Shell layout scanned as discovery root | Set `runtimeProvider: true` only on provider roots; keep shell layouts unflagged |
| Package not discovered | Layout outside `layouts/` / `components/`, or import not reachable from `render` | Move layout file or add explicit `runtimeModules` entry |
| `@/` alias not followed | Missing or invalid tsconfig paths | Add `compilerOptions.paths`; ensure `include` globs are valid JSON |
| Stale bootstrap script hash in dev | Rendered HTML cached before runtime vendors changed | Rebuild or touch a route file; dev HTML cache keys include browser-runtime generation |

Use the dev toolbar **Deps** panel to inspect vendor URLs loaded by the active page. See [Dev Toolbar](/docs/core/dev-toolbar).

## Related guides

- [React Router](/docs/ecosystem/react-router) — SPA navigation and layout persistence
- [Creating Layouts](/docs/core/layouts) — nested layout stacks and `runtimeProvider`
- [Dev Toolbar](/docs/core/dev-toolbar) — inspect page browser graph entries and vendor URLs
