Plain HTML
PulsePoint does not need a backend integration to be useful. A single static .html file is already a complete PulsePoint app: one module import, one bootstrap call, and your components.
This page covers the no-server setup — prototypes, demos, landing pages, anything you can drop on static hosting. If your server renders the HTML, read Implement In Your Backend instead: it can emit the same markup automatically.
A complete page
Copy this into a file, serve it, and the counter works. There is nothing else to install.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PulsePoint V2</title>
<script type="module">
import { ComponentInit as PP } from "https://pulse-point-cdn.pages.dev/pp-reactive-v2.min.js";
PP.bootstrap();
</script>
</head>
<body>
<template pp-component="page_06cde1cb">
<div>
<p>Count: {count}</p>
<button onclick="setCount(count + 1)">Increment</button>
<button onclick="setCount(count - 1)">Decrement</button>
<script>
const [count, setCount] = pp.state(0);
</script>
</div>
</template>
</body>
</html>
Serve it over http:// — any static
server will do. Opening the file straight from disk is unreliable, because ES
module imports are CORS-checked and a file://
page has an opaque origin.
What each part does
-
The runtime is an ES module. It has two named exports,
ComponentInitandPPUtilities, so the script tag must betype="module". Renaming the import toPPis style, not a requirement. -
Module scripts are deferred. They run after the document is parsed, so the body already
exists by the time
PP.bootstrap()is called. NoDOMContentLoadedlistener is needed, and the tag can stay in<head>. -
Importing the runtime does not start it. The import defines the global
ppobject and nothing more. Something has to callPP.bootstrap()orpp.mount(); without it the page stays inert and renders literal{count}text. -
PP.bootstrap()does three things: it materializes every<template pp-component>into live DOM, resolves fragment boundary markers, and creates one component instance per top-levelpp-componentroot in the body. -
The component id is yours to pick.
page_06cde1cbis just a string; it only has to be unique within the page.
To pin a version, download the runtime and import it from your own assets instead of the CDN — see Installation.
<script type="module">
import { ComponentInit as PP } from "/js/pp-reactive-v2.min.js";
PP.bootstrap();
</script>
Why the <template> wrapper
In a served app the backend adds these wrappers for you. In a hand-written page
you add them yourself, and in a page with no server they are what makes the
example above work at all. A
<template> element's contents are
inert: the browser parses them but never puts them in the live document.
That buys two things.
-
The component's
<script>does not run at parse time. This is the one that bites. Because the module import is deferred, a<script>sitting directly in the document executes before the runtime exists, and the console fills withReferenceError: pp is not defined. Inside a template it is never executed by the browser at all — PulsePoint captures the source and evaluates it once, in component scope. -
No flash of raw placeholders. Unrendered
{count}text never reaches the live document, so there is nothing to paint before compilation finishes.
This is the shape to avoid in a plain HTML page:
<!-- Broken in a plain HTML page: this <script> runs during parsing,
before the deferred module import has defined `pp`. -->
<div pp-component="page_1">
<p>Count: {count}</p>
<script>
const [count, setCount] = pp.state(0);
</script>
</div>
The wrapper does not change what a component is. PulsePoint follows the React
component pattern — one root element per component, carrying
pp-component, with the whole component
including its <script> inside it. That single
root is what goes in the template, and bootstrap copies the template's attributes
onto it before replacing the template with it. So the two rules follow from the
one: put pp-component on the
<template> rather than repeating it inside,
and put exactly one element in there — only the first child becomes the root.
Do not hide the body when you use bootstrap()
A common PulsePoint pattern is <body style="opacity: 0">,
revealed once hydration finishes. That reveal is part of
pp.mount(), not of
PP.bootstrap(). Combine the two and the page
compiles correctly but stays invisible forever, with nothing in the console. With
the template wrappers above you do not need the trick at all.
If you prefer the body-opacity approach, call pp.mount() instead:
<head>
<script type="module">
import "https://pulse-point-cdn.pages.dev/pp-reactive-v2.min.js";
pp.mount();
</script>
</head>
<body style="opacity: 0">
<!-- pp.mount() restores your original inline style after compiling -->
</body>
| ComponentInit.bootstrap() | pp.mount() | |
|---|---|---|
| Compiles every top-level pp-component root | Yes | Yes |
| Materializes <template pp-component> wrappers | Yes | Yes |
| Hides the body during hydration, then restores your inline style | No | Yes |
| SPA link interception and scroll restoration | No | Yes |
| Safe to call more than once | No — call it exactly once | Yes — later calls are no-ops |
pp.mount() also turns on SPA link navigation, which
fetches same-origin links over the network and swaps the page in place. That is
the right default for a multi-page server-rendered app; for a single static file
it changes nothing, and for a static site of several pages it is a behavior
choice rather than a free win.
Call bootstrap() exactly once
bootstrap() is not idempotent. Running it a
second time builds a fresh component instance over each already-mounted root, and
the new instance has no script left to capture — the first mount already consumed
it. The rendered markup keeps its current text, so the page still looks right, but
the handlers now run against an empty scope. The tell is in the console:
[PP-ERROR] Handler failed: ReferenceError: setCount is not defined
on a page whose markup obviously does define it.
That means new UI should come from component state rather than from injecting markup and re-bootstrapping. Loops, conditionals and portals all work with no server behind them:
<template pp-component="shopping_list">
<div>
<form onsubmit="{addItem(event)}">
<input name="label" placeholder="Add an item" required>
<button type="submit">Add</button>
</form>
<ul>
<template pp-for="item in items">
<li key="{item.id}">{item.label}</li>
</template>
</ul>
<script>
const [items, setItems] = pp.state([]);
const addItem = (event) => {
event.preventDefault();
const form = event.currentTarget;
const { label } = Object.fromEntries(new FormData(form).entries());
setItems([...items, { id: crypto.randomUUID(), label }]);
form.reset();
};
</script>
</div>
</template>
Several components on one page
Add as many roots as you like. Each one gets its own scope, its own state and its own script — the two counters below share nothing.
<body>
<template pp-component="counter_a">
<div>
<p>A: {count}</p>
<button onclick="setCount(count + 1)">+</button>
<script>
const [count, setCount] = pp.state(0);
</script>
</div>
</template>
<template pp-component="counter_b">
<div>
<p>B: {count}</p>
<button onclick="setCount(count + 1)">+</button>
<script>
const [count, setCount] = pp.state(100);
</script>
</div>
</template>
</body>
What works without a server
Everything browser-resident:
state,
effects,
refs,
keyed loops,
portals,
context, memos and every
directive.
You can still reach the network with plain
fetch inside an effect or a handler.
Two APIs are the exception, because they are contracts with a server rather than
browser features: pp.rpc() needs an endpoint that
speaks the RPC contract, and
pp.socket() needs a
WebSocket endpoint. Both are additive: a page
that starts as one static file keeps working exactly as written once you put a
backend behind it.