Skip to content

Vanilla JS Integration

The Vanilla JS integration lets you build Astro Islands using plain HTML and JavaScript — no framework required. A component is a TypeScript file that returns an HTML string, with interactivity handled via inline <script> tags.

  • You need a simple interactive component and don’t want to pull in a full framework.
  • You are migrating existing vanilla JS/HTML code into an island.

The integration is registered automatically when you scaffold with create-outsystems-astro. No additional configuration is needed.

A Vanilla JS component is a TypeScript function that accepts props and returns an HTML string.

src/framework/vanilla/MyComponent.ts
export default function MyComponent({
children = "",
header = "",
initialCount = 0,
}: {
children?: string;
header?: string;
initialCount?: number;
}): string {
return `
${header}
<div class="my-component">
<pre class="count">${initialCount}</pre>
<button class="add">+</button>
${children}
<script>
(function () {
const container = (document.currentScript && document.currentScript.parentElement)
|| document.querySelector('.my-component');
let count = ${initialCount};
const countEl = container.querySelector('.count');
container.querySelector('.add').addEventListener('click', function () {
count += 1;
countEl.textContent = count;
});
})();
</script>
</div>
`;
}

*** Note ***: Be careful when using global variables in the script tag. You will most likely want to avoid any Javascript variables or CSS styles from leaking out of the island.

Use client:load on the component in your .astro page:

---
import MyComponent from "../../framework/vanilla/MyComponent";
import styles from "../../styles/index.css?url";
const initialCount = 5;
const showMessage = "showMessage";
---
<html lang="en">
<head>
<link href={styles} rel="stylesheet" />
<script>
window["showMessage"] = (count) => {
document.getElementById("counter").textContent = count;
};
</script>
</head>
<body>
<MyComponent
client:load
initialCount={initialCount}
showMessage={showMessage}
>
</MyComponent>
</body>
</html>

client:load is used instead of client:only because the Vanilla JS renderer uses it to associate the client entrypoint with the island. The server rendering step intentionally returns empty HTML so the output only contains the slot templates and props — identical to how client:only frameworks like React behave.

Slots are not supported in the Vanilla JS integration.

There is no Nano Stores binding library for the Vanilla JS integration the way there is for React or Vue, so the component uses the vanilla JS API against a real atom.

Register the atom from the component module. The module runs in the browser, so it can import from your stores folder, but the renderer also imports it during the build — guard the call so it only runs client side:

import { setupStore } from "../../stores/demo";
if (typeof window !== "undefined") {
setupStore("myStore");
}
export default function MyComponent(): string {
return `
<div class="my-component">
<div class="store-value"></div>
<script>
(function () {
const container = (document.currentScript && document.currentScript.parentElement)
|| document.querySelector('.my-component');
const valueEl = container.querySelector('.store-value');
const store = window.Stores && window.Stores['myStore'];
if (store) {
store.subscribe(function (value) {
valueEl.textContent = value;
});
}
})();
</script>
</div>
`;
}

The inline <script> is re-created as a classic script when the island hydrates, so it cannot import — it reads the atom from window.Stores instead. subscribe fires immediately with the current value, so there is no need to read .get() first, and the guard keeps the island from throwing when no store has been registered.

The page can register the same store instead of, or as well as, the component. setupStore returns the atom that is already registered under that name:

<script>
import { setupStore } from "../../stores/demo";
setupStore("myStore");
</script>

Pass the handler name as a string prop and call it via window:

export default function MyComponent({
initialCount = 0,
showMessage = "",
}: {
initialCount?: number;
showMessage?: string;
}): string {
return `
<div class="my-component">
<button class="send">Send value</button>
<script>
(function () {
const container = (document.currentScript && document.currentScript.parentElement)
|| document.querySelector('.my-component');
let count = ${initialCount};
container.querySelector('.send').addEventListener('click', function () {
if ('${showMessage}' && window['${showMessage}']) {
window['${showMessage}'](count);
}
});
})();
</script>
</div>
`;
}

Use @testing-library/dom directly. Render the component by calling the function and setting document.body.innerHTML, then execute the scripts using new Function so they run in the test’s global context:

import { fireEvent, screen } from "@testing-library/dom";
import MyComponent from "../../../src/framework/vanilla/MyComponent";
function renderComponent(props = {}) {
document.body.innerHTML = MyComponent(props);
document.body.querySelectorAll("script").forEach((script) => {
new Function(script.textContent ?? "")();
});
}
test("increments counter", () => {
renderComponent({ initialCount: 5 });
fireEvent.click(screen.getByRole("button"));
expect(document.querySelector(".count")?.textContent).toBe("6");
});

new Function is required because innerHTML does not execute <script> tags, and replaceChild does not execute scripts in happy-dom.

Register a real atom on window.Stores before rendering, then drive it with set to assert the island updates:

import { atom } from "nanostores";
let store = atom("Initial value");
beforeEach(() => {
store = atom("Initial value");
(window as { Stores: Record<string, unknown> } & Window).Stores = {
myStore: store,
};
});
test("reflects store updates", () => {
renderComponent();
expect(document.querySelector(".store-value")?.textContent).toBe(
"Initial value",
);
store.set("Updated value");
expect(document.querySelector(".store-value")?.textContent).toBe(
"Updated value",
);
});

Use Playwright as with any other framework. Navigate to the page and interact with elements normally:

import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/vanilla/my-component");
});
test("increments counter", async ({ page }) => {
await page.getByRole("button", { name: "+" }).click();
await expect(page.locator("pre")).toContainText("6");
});