Creating Pages in Ecopages

Pages are the core of your Ecopages project. They represent the different routes and content of your website. This guide uses Ecopages JSX (.tsx routes owned by @ecopages/ecopages-jsx), which matches the Installation default. Other integrations use the same eco.page API with different file extensions.

Basic Page Structure

A typical page is a single .tsx file under src/pages:

import { BaseLayout } from '@/layouts/base-layout';
import { eco } from '@ecopages/core';
 
export default eco.page({
	layout: BaseLayout,
	metadata: () => ({
		title: 'Home page',
		description: 'This is the homepage of the website',
	}),
	render: () => (
		<>
			<h1>Ecopages</h1>
			<p>Welcome to my Ecopages website!</p>
		</>
	),
});

Key elements:

  1. eco.page: Factory for a file-based route page.
  2. layout: Declared layout component (eco.layout() or eco.component()). EcoPages wraps render output automatically.
  3. metadata: Page title, description, and social metadata.
  4. dependencies: Page-level scripts and components discovery cannot follow (export * barrels, packages). Local component and CSS imports are discovered automatically.
  5. render: Returns JSX for the page body (inside the layout and document shell).

See Creating Layouts for single and nested layout stacks.

Using Components in Pages

Import declared components directly. Ecopages discovers local eco.component(), eco.layout(), and eco.html() imports automatically. Every discovered entry must be a declared component — plain functions are rejected at render time.

import { RadiantCounter } from '@/components/radiant-counter';
import { BaseLayout } from '@/layouts/base-layout';
import { eco } from '@ecopages/core';
 
export default eco.page({
	layout: BaseLayout,
	render: () => (
		<>
			<h1>Ecopages</h1>
			<RadiantCounter count={5} />
		</>
	),
});

Note: You do not need to repeat the page layout in dependencies.components — eco.page() merges declared layouts into the dependency graph for you. Use explicit dependencies.components only when discovery cannot follow the import (export * barrels, packages).

Working with Data

For build-time and request-time data, see Data Fetching.

Lazy browser scripts

Lazy loading is configured on dependencies.scripts entries. Add ssr: true when the script registers a custom element that must SSR (see Components):

import { eco } from '@ecopages/core';
 
export const MyComponent = eco.component({
	dependencies: {
		scripts: [{ src: './my-component.script.ts', ssr: true, lazy: { 'on:interaction': 'mouseenter,focusin' } }],
	},
	render: (props) => <my-component {...props} />,
});

See Components for more patterns.

HTML Pages

A plain .html file under src/pages is an HTML Page. It needs no Integration, plugin, or config field: src/pages/about.html becomes /about, rendered inside your HTML template with the app's default Cache Strategy ('static' unless cache.defaultStrategy says otherwise).

An HTML Page has no props, template expressions, Layout, data hooks, or hydration. Use an eco.page() Page when a route needs any of those.

Three rules cover the contract:

  1. Write normal HTML. A Page can be a body fragment, a <head> followed by body markup, or a complete document with doctype, <html>, <head>, and <body>. As in a browser, <head> is optional: the <title>, <meta>, <link>, <style>, <script>, <base> and <template> tags that start the Page form its head, up to the first other element or text. A <script> or <style> placed before the first body element therefore goes to the head; put it after that element to keep it in the body.
  2. Page head tags win by identity. A Page <title>, <base>, <link rel="canonical">, or <meta> with the same name, property, or http-equiv as a shell tag replaces that shell tag in place. Every other Page head tag is added before </head>.
  3. Core rewrites only local assets. Within your markup, core changes only the URLs of local stylesheets and scripts, of preload hints for them, and the contents of <style> blocks, and emits them where you wrote them; a file html.html already loads is emitted once, by the shell. Everything else, including inline scripts, is kept as written.

Minimal app

An HTML-only app needs @ecopages/core and the ecopages CLI installed, and four files.

package.json:

{
	"type": "module",
	"scripts": {
		"dev": "ecopages dev",
		"build": "ecopages build",
		"typecheck": "ecopages types && tsc --noEmit"
	}
}

app.ts:

import { createApp } from '@ecopages/core/create-app';
 
const app = await createApp();
 
await app.start();

eco.config.ts:

import { defineConfig } from '@ecopages/core/config';
 
export default defineConfig({});

src/pages/index.html:

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="utf-8">
		<title>Hello World</title>
	</head>
	<body>
		<h1>Hello from Ecopages</h1>
	</body>
</html>

ecopages dev serves /, and ecopages build exports it. The output has one doctype, one <html>, and one <meta charset>: core drops the Page doctype and wrappers, so the shell provides the only document structure.

To start from a complete app instead, run ecopages init my-app --template html.

HTML template

HTML Pages render inside your HTML template, src/includes/html.*, whichever Integration owns it, so an existing JSX or React template keeps working. You can also write the template in plain HTML as src/includes/html.html (see Plain HTML template); the app can have only one html template file. It needs an <html> element with <head> and <body>, and exactly one <!-- eco:children --> comment where Page content goes. The doctype is optional; core always emits one.

src/includes/html.html:

<html lang="en">
	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width, initial-scale=1">
		<title>My site</title>
		<meta name="description" content="Small, fast websites.">
		<link rel="stylesheet" href="./site.css">
	</head>
	<body>
		<header><a href="/">My site</a></header>
		<!-- eco:children -->
	</body>
</html>

Without any src/includes/html.*, HTML Pages use a built-in HTML template with lang="en", a UTF-8 charset, and a responsive viewport. Pages owned by other Integrations still need an HTML template of their own.

Head and root attributes

With the shell above, this Page replaces the shell title and description in place, and adds its canonical link and stylesheet before </head>.

src/pages/about.html:

<head>
	<title>About us</title>
	<meta name="description" content="Who we are.">
	<link rel="canonical" href="https://example.com/about">
	<link rel="stylesheet" href="./about.css">
</head>
<main>
	<h1>About us</h1>
	<img src="/images/team.jpg" alt="Our team">
	<my-counter count="0"></my-counter>
</main>
<script type="module" src="./counter.ts"></script>

Two head tags are placed differently:

  • <meta charset> belongs to the shell, so it stays early in the document. A Page charset with the same encoding is dropped, and a different encoding is an error. If the shell has none, the Page charset goes at the start of <head>.
  • <base> without a shell copy also goes at the start of <head>, so it applies to every URL after it.

Attributes on the Page <html> and <body> merge onto the shell's: class values are joined, and any other attribute replaces the shell value. If a JSX shell leaves <body> to its Layouts, core wraps the Page markup in a <body>.

You can paste an existing document and keep its lang, classes, and scripts, as long as its images and icons use root-relative URLs and its local assets live inside src without integrity attributes (see below).

Core also reads these Page head tags into the page metadata, merged over defaultMetadata. JSX and React shells render from that metadata, and static export uses it for the Sitemap. A <meta> tag is matched by its value, so name, property and http-equiv are interchangeable: <meta name="og:image"> fills image too.

Page head tagMetadata field
<title>title
<meta name="description">description
<meta name="keywords">keywords, split on commas
<meta property="og:image">image
<link rel="canonical">url
<meta name="robots">robots; noindex removes the route from the Sitemap

Core also derives social tags from a Page's own head: when a Page sets a <title> or description, it gets matching og:title, og:description, twitter:title, and twitter:description tags, which replace the shell's tag with the same value, whichever attribute it uses, or are added before </head>. A tag the Page writes itself wins, whichever attribute it uses. Other social tags, such as og:image, og:type, or twitter:card, are written by hand, usually as site defaults in html.html. Core also adds a robots <meta> from metadata.robots (see Plain HTML template).

Stylesheets, scripts, and other assets

A relative path, with or without ./, resolves against the .html file that contains the tag, in a Page or in html.html:

TagResult
<link rel="stylesheet" href="./page.css">Run through your stylesheet processors, linked to the output
<style>…</style>Run through your stylesheet processors, kept inline
<script type="module" src="./page.ts">Bundled as a browser module; a module several scripts import loads once, across Pages too. CSS comes from a <link>: import './page.css' or import 'some-package/style.css' in the script or a local module it imports is an error naming the HTML file and the stylesheet
<script src="./legacy.js">Compiled on its own as a classic script, minified in production
<script src="./legacy.ts">Compiled on its own as a classic script, minified in production
<link rel="modulepreload" href="./page.ts">, <link rel="preload" as="style">, or <link rel="preload" as="script">Points at the output of the module script, stylesheet, or classic script with that file in the Page or html.html. Use rel="modulepreload" for a module script. A preload that matches none is kept as written, with a warning in development
A tag with an external or root-relative URLKept as written
An inline <script type="module"> that imports a relative path, such as import './x.ts'An error naming the HTML file: move the code to a file and load it with <script type="module" src>
Any other inline <script>, or one with a non-JavaScript typeKept as written

Processed tags keep their other attributes. A file declared by both html.html and the Page is emitted once, by the shell, and a file declared twice in one file is emitted at its first occurrence. A classic script keeps classic semantics: its top-level functions and variables are globals other scripts can use, and this at the top level is the global object. Each one, JavaScript or TypeScript, is compiled on its own, never bundled, and minified in production without renaming its top-level names; any tsconfig.json is ignored. A classic script cannot use import, export, import.meta, a relative import(), a top-level await or a decorator, and cannot be a .jsx or .tsx file; each is an error asking for type="module". Two classic scripts that would be written to the same output, such as legacy.ts beside legacy.js, are an error naming both files. A missing local file, a path outside src, or an integrity attribute on a processed tag or on a preload that core points at a processed file is an error that names the HTML file.

Images, fonts, media, and icons are not processed. Put them in src/public/ and reference them with root-relative URLs such as /images/team.jpg. In development, core warns once per file change about a relative URL that it leaves as written, because the browser resolves it against the route URL rather than the file: src, srcset, imagesrcset, href, SVG xlink:href, poster, <object data>, and url() in a style attribute or in processed <style> output. Links (<a>, <area>, <base>) are not warned about. Social image tags (og:image, og:image:url, og:image:secure_url, twitter:image, twitter:image:src) are warned about unless their content is an absolute URL such as https://example.com/og.png, because link previews cannot load a relative or root-relative one.

Custom elements, including Lit and Radiant components, work when the Page references the module that defines them. They are not server-rendered, so their content appears once the module runs. To enable client navigation, install @ecopages/browser-router and reference a module from the shell that starts the router:

<script type="module" src="./router.ts"></script>
// src/includes/router.ts
import { createRouter } from '@ecopages/browser-router/client';
 
createRouter();

After each client navigation, a head script runs only if the previous page's head did not have it, and every body script runs again, in document order; an external module script runs at most once per URL in a tab. An external classic body script therefore runs on every visit to its Page, and its top-level const, let or class declarations would be declared twice and throw. Write such a script without them (use var or functions, or wrap the code in a block or an IIFE), or use a module script, which runs once per URL, and listen for eco:page-load for code that must run on each visit.

Error pages and route rules

Core serves src/pages/404.html for unmatched routes and src/pages/500.html when a page fails to render, both inside the shell. Core passes error pages the status and message, but an HTML Page cannot read props, so these pages show fixed content.

A dynamic filename such as [slug].html is an error, because an HTML Page has no staticPaths to expand it. Two Page files that produce the same route, such as about.html next to about.tsx or about/index.html, are also an error.