---
title: "Custom Integration"
description: "Add support for new templating engines and frameworks via custom integrations."
order: 4
---

# Creating Custom Integrations

Integrations in Ecopages add support for new templating engines or frameworks. A custom integration usually owns four things:

1. which file extensions belong to the framework
2. which renderer turns those files into HTML
3. which build-time contributions must be registered before startup
4. which runtime-only behavior is needed for HMR, browser bundles, or cross-integration boundaries

Core still owns lifecycle ordering. Integration plugins declare contributions; `ConfigBuilder.build()` and app startup decide when those hooks run.

## Minimal Integrations

For string-markup integrations that only need declarative config and a renderer class, use `defineIntegration()` instead of writing a plugin class by hand:

```typescript
import { defineIntegration } from '@ecopages/core/plugins/define-integration';
import { StringMarkupRenderer } from '@ecopages/core/route-renderer/orchestration/string-markup-renderer';

class CustomRenderer extends StringMarkupRenderer {
	name = 'custom-integration';
}

export const customPlugin = defineIntegration({
	name: 'custom-integration',
	extensions: ['.custom'],
	renderer: CustomRenderer,
});
```

`customPlugin()` returns a configured integration instance. `customPlugin.Plugin` exposes the generated class when you need an explicit constructor.

For integrations with runtime services, HMR strategies, or build loaders, continue using the class-based pattern below.

## Basic Structure

An integration extends `IntegrationPlugin` and provides a renderer class. Keep the constructor declarative: use it to describe ownership, not to perform runtime side effects.

```typescript
import type { EcoPagesElement } from '@ecopages/core';
import { IntegrationPlugin, type IntegrationPluginConfig } from '@ecopages/core/plugins/integration-plugin';
import { CustomRenderer } from './custom-renderer.ts';

export class CustomIntegration extends IntegrationPlugin<EcoPagesElement> {
	renderer = CustomRenderer;

	constructor(options?: Omit<IntegrationPluginConfig, 'name' | 'extensions'>) {
		super({
			name: 'custom-integration',
			extensions: ['.custom'],
			...options,
		});
	}
}
```

Static pages are generated by executing each route's integration renderer directly during `ecopages build`.

## Declaring Runtime Requirements

Declare runtime requirements in `runtimeCapability` so Ecopages can fail during config finalization instead of during startup.

```typescript
class CustomIntegration extends IntegrationPlugin {
	renderer = CustomRenderer;

	constructor() {
		super({
			name: 'custom-integration',
			extensions: ['.custom'],
			runtimeCapability: {
				tags: ['node-compatible'],
				minRuntimeVersion: '20.0.0',
			},
		});
	}
}
```

Available tags:

1. `node-compatible`
2. `requires-node-builtins`
3. `bun-only`
4. `requires-native-bun-api`

Use the smallest declaration that matches reality. For example, an integration that only needs standard Node-compatible APIs should not declare Bun-only requirements.

`runtimeCapability` is validated during `ConfigBuilder.build()`, before `setup()` runs. Integrations that depend on Bun-native APIs should declare that requirement explicitly.

## Creating a Custom Renderer

The renderer handles framework-specific HTML generation. It extends `IntegrationRenderer` and implements `render()`.

```typescript
import { IntegrationRenderer } from '@ecopages/core/route-renderer/orchestration/integration-renderer';
import type { EcoComponent, IntegrationRendererRenderOptions, RouteRendererBody } from '@ecopages/core';

export class CustomRenderer extends IntegrationRenderer<string> {
	name = 'custom-integration';

	async render(options: IntegrationRendererRenderOptions): Promise<RouteRendererBody> {
		const { Page, props, metadata, HtmlTemplate } = options;
		const pageContent = await this.renderPage(Page, props);

		return String(
			await HtmlTemplate({
				children: pageContent,
				metadata,
				pageProps: props ?? {},
			}),
		);
	}

	private async renderPage(Page: EcoComponent, props: Record<string, unknown> | undefined): Promise<string> {
		return String(await Page(props ?? {}));
	}
}
```

`IntegrationRenderer` already owns shared orchestration such as dependency collection, page-module loading, marker-graph resolution, and document finalization. Custom renderers should focus on framework semantics rather than rebuilding core’s route pipeline.

`HtmlTemplate` receives `children`, `metadata`, and `pageProps`. Examples should include `pageProps` to match the current public contract.

## Handling Dependencies

Use `integrationDependencies` for global framework assets that should apply everywhere the integration is active.

```typescript
import { AssetFactory } from '@ecopages/core/services/asset-processing-service';

class CustomIntegration extends IntegrationPlugin {
	renderer = CustomRenderer;

	constructor() {
		super({
			name: 'custom-integration',
			extensions: ['.custom'],
			integrationDependencies: [
				AssetFactory.createFileScript({
					filepath: './runtime.js',
					position: 'head',
				}),
				AssetFactory.createFileStylesheet({
					filepath: './styles.css',
				}),
			],
		});
	}
}
```

Use `prepareBuildContributions()` for build-facing declarations that must exist before the app manifest is sealed, such as optional loaders or browser-only build plugins. Use `setup()` only for runtime-only work.

```typescript
override async prepareBuildContributions(): Promise<void> {
	// Register build-facing plugins here.
}

override async setup(): Promise<void> {
	await super.setup();
	// Runtime-only side effects go here.
}
```

Use `browserBuildPlugins` for browser-only bundling behavior. `browserBuildPlugins` are intentionally excluded from server bundles and static page-module generation.

## Cross-Integration Ownership

Cross-integration ownership belongs in the renderer. When a renderer must keep control of its subtree even after another integration encounters it, implement renderer-owned foreign-child handling in the `IntegrationRenderer` subclass.

The runtime contract has three steps:

1. **Queue** — Override `createForeignChildRuntime()` for custom interception, or call `this.foreignSubtreeExecutionService.createQueuedRuntime(...)` (the base class default).
2. **Resolve** — After local HTML is produced in `renderComponent`, call `this.foreignSubtreeExecutionService.resolveQueuedHtml(...)` (string-markup paths can use `resolveStringQueuedHtml` / `renderStringComponentWithQueuedForeignSubtrees`).
3. **Own** — Pass `getOwningRenderer` that calls `resolveOwningIntegrationRenderer` from `@ecopages/core/route-renderer/orchestration/foreign-child/owning-renderer-resolution`. The same module exports `getForeignSubtreeTokenPrefix` and `getForeignSubtreeResolutionContextKey`.

Prefer extending `StringMarkupRenderer` when the integration is string-first — queue and resolve are already wired. Integrations such as Lit and React use the explicit resolve step because the owning renderer must re-enter during foreign-subtree resolution to produce the correct SSR output.

```typescript
import {
	getForeignSubtreeResolutionContextKey,
	resolveOwningIntegrationRenderer,
} from '@ecopages/core/route-renderer/orchestration/foreign-child/owning-renderer-resolution';

const queued = await this.foreignSubtreeExecutionService.resolveQueuedHtml({
	currentIntegrationName: this.name,
	html,
	runtimeContext: this.foreignSubtreeExecutionService.getQueuedRuntimeContext(
		input,
		getForeignSubtreeResolutionContextKey(this.name),
	),
	queueLabel: this.name,
	getOwningRenderer: (integrationName, rendererCache) =>
		resolveOwningIntegrationRenderer({
			appConfig: this.appConfig,
			runtimeOrigin: this.runtimeOrigin,
			currentIntegrationName: this.name,
			currentRenderer: this,
			integrationName,
			cache: rendererCache,
		}),
	applyAttributesToFirstElement: (resolvedHtml, attributes) =>
		this.htmlTransformer.applyAttributesToFirstElement(resolvedHtml, attributes),
	dedupeProcessedAssets: (assets) => this.htmlTransformer.dedupeProcessedAssets(assets),
	renderQueuedChildren: (children, _ctx, byToken, resolveToken) =>
		this.renderQueuedForeignSubtreeChildren(children, byToken, resolveToken),
});
```

## Hot Module Replacement (HMR)

To support HMR in a custom integration, implement an `HmrStrategy` and register it in the plugin.

### Creating an HMR Strategy

The strategy determines how file changes are handled. It should identify matches and process the updates:

```typescript
import {
	HmrStrategy,
	HmrStrategyType,
	type HmrAction,
} from '@ecopages/core/hmr/hmr-strategy';
import type { DefaultHmrContext } from '@ecopages/core';

class CustomHmrStrategy extends HmrStrategy {
	readonly type = HmrStrategyType.INTEGRATION;

	constructor(private context: DefaultHmrContext) {
		super();
	}

	matches(filePath: string): boolean {
		return filePath.endsWith('.custom');
	}

	async process(filePath: string): Promise<HmrAction> {
		const watchedFiles = this.context.getWatchedFiles();
		const outputUrl = watchedFiles.get(filePath) || filePath;

		return {
			type: 'broadcast',
			events: [
				{
					type: 'update',
					path: outputUrl,
					timestamp: Date.now(),
				},
			],
		};
	}
}
```

### Registering the Strategy

Override `getHmrStrategy()` to return your custom strategy:

```typescript
class CustomIntegration extends IntegrationPlugin {
	renderer = CustomRenderer;

	override getHmrStrategy() {
		if (!this.hmrManager) {
			return undefined;
		}

		return new CustomHmrStrategy(this.hmrManager.getDefaultContext());
	}
}
```

Use `browserBuildPlugins` when the integration needs browser-only transforms in client bundles (for example alias or rewrite plugins from `@ecopages/core/build/browser-runtime-plugin` or `@ecopages/core/plugins/alias-resolver-plugin`). Override `setHmrManager()` only when behavior beyond the shared strategy registration path is required.

## Setup and Teardown

Use `setup()` and `teardown()` to initialize and clean up integration resources:

```typescript
class CustomIntegration extends IntegrationPlugin {
	renderer = CustomRenderer;

	async setup(): Promise<void> {
		await super.setup();
		// Additional initialization logic
		// e.g., Bun.plugin(customCompilerPlugin())
	}

	async teardown(): Promise<void> {
		// Clean up resources when the process stops
	}
}
```

Core does not call `teardown()` by default. Override it only when the integration owns watchers, compiler handles, or other resources that outlive individual requests.

`prepareBuildContributions()` runs before `setup()`. Use that split consistently:

1. `prepareBuildContributions()` for loaders, manifest-facing plugins, and other build declarations
2. `setup()` for runtime-only side effects
3. `teardown()` for explicit cleanup when the process stops

## Using the Integration

Register the integration in the Ecopages configuration:

```typescript
import { ConfigBuilder } from '@ecopages/core/config-builder';
import { CustomIntegration } from './custom-integration';

const customIntegration = new CustomIntegration();

const config = await new ConfigBuilder()
	.setBaseUrl('https://example.com')
	.setRootDir(process.cwd())
	.setIntegrations([customIntegration])
	.build();
```

`ConfigBuilder.build()` is the app-owned finalization boundary. This is where Ecopages validates runtime requirements, derives paths, collects build contributions, and prepares the shared runtime state used later by startup and rendering.

## Best Practices

1. Keep the constructor declarative. Build-facing work goes in `prepareBuildContributions()`. Runtime side effects go in `setup()`.
2. Keep the renderer focused on framework semantics. Core already owns route orchestration, dependency assembly, and marker-graph resolution.
3. Declare the smallest valid `runtimeCapability`. Overstating requirements makes the integration unusable on compatible runtimes.
4. Use `browserBuildPlugins` for browser-only transforms and runtime specifier aliasing in browser bundles.
5. Fall back to full reload when a hot update is not safe. Correctness matters more than partial HMR.
6. Document any required foreign-child ownership rules. Cross-integration rendering is the first place future readers will get lost.

## Example: Complete Integration

Here's a complete example including the plugin, renderer, and HMR strategy:

```typescript
import { ConfigBuilder } from '@ecopages/core/config-builder';
import { IntegrationPlugin } from '@ecopages/core/plugins/integration-plugin';
import { IntegrationRenderer } from '@ecopages/core/route-renderer/orchestration/integration-renderer';
import { HmrStrategy, HmrStrategyType, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
import type {
	DefaultHmrContext,
	EcoComponent,
	IntegrationRendererRenderOptions,
	RouteRendererBody,
} from '@ecopages/core';

class CustomRenderer extends IntegrationRenderer<string> {
	name = 'custom-integration';

	async render(options: IntegrationRendererRenderOptions): Promise<RouteRendererBody> {
		const { Page, props, metadata, HtmlTemplate } = options;
		const pageContent = String(await Page(props ?? {}));
		return String(await HtmlTemplate({ children: pageContent, metadata, pageProps: props ?? {} }));
	}
}

class CustomHmrStrategy extends HmrStrategy {
	readonly type = HmrStrategyType.INTEGRATION;

	constructor(private context: DefaultHmrContext) {
		super();
	}

	matches(filePath: string) {
		return filePath.endsWith('.custom');
	}

	async process(filePath: string): Promise<HmrAction> {
		const outputUrl = this.context.getWatchedFiles().get(filePath) || filePath;
		return {
			type: 'broadcast',
			events: [{ type: 'update', path: outputUrl, timestamp: Date.now() }],
		};
	}
}

export class CustomIntegration extends IntegrationPlugin {
	renderer = CustomRenderer;

	constructor() {
		super({
			name: 'custom-integration',
			extensions: ['.custom'],
		});
	}

	override getHmrStrategy() {
		return this.hmrManager
			? new CustomHmrStrategy(this.hmrManager.getDefaultContext())
			: undefined;
	}
}

const config = await new ConfigBuilder()
	.setBaseUrl('https://example.com')
	.setRootDir(process.cwd())
	.setIntegrations([new CustomIntegration()])
	.build();
```
