Astro HTML: from an Astro page to one HTML file you can open

Astro, the web framework from astro.build, writes plain HTML when you build a project. Here is what that HTML looks like, how to keep it in one file, and why an .astro file cannot be opened directly.

Astro here means the web framework from astro.build, not astrology or any other product called Astro. It builds a project into static files. The built index.html is an ordinary page, and with two settings it holds its own styles and script.

Here is a page shaped like one Astro builds: plain HTML, inline styles, and one small script. Click the button.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Built page</title>
<style>
  /* In an Astro build, small stylesheets are inlined like this */
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  body.dark { background: #161b26; color: #e6e9f0; }
  .page { max-width: 460px; margin: 0 auto; }
  h1 { font-size: 22px; margin: 0 0 6px; }
  p { margin: 0 0 14px; line-height: 1.5; }
  ul { padding: 0; margin: 0 0 14px; list-style: none; }
  li { padding: 10px 12px; margin-bottom: 8px; border-radius: 8px; background: #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, .1); }
  body.dark li { background: #232b3b; }
  button { font: inherit; padding: 8px 14px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  #state { margin-left: 8px; font-size: 13px; opacity: .8; }
</style>
</head>
<body>
<div class="page">
  <h1>My notes</h1>
  <p>Everything above the button is plain HTML. One small script handles the button.</p>
  <ul>
    <li>Install Astro</li>
    <li>Run the build</li>
    <li>Open the HTML it wrote</li>
  </ul>
  <button id="toggle" type="button">Toggle dark mode</button><span id="state">light</span>
</div>

<script type="module">
  // The same shape as a bundled Astro script: one tag, no imports needed here
  const btn = document.getElementById('toggle');
  const state = document.getElementById('state');
  btn.addEventListener('click', () => {
    const dark = document.body.classList.toggle('dark');
    state.textContent = dark ? 'dark' : 'light';
  });
</script>
</body>
</html>
A single file shaped like Astro output. Everything is plain HTML except one script for the button.

You can edit this file and paste it anywhere HTML runs. The rest of this guide covers how to get from an Astro project to a file like it.

An .astro file is not HTML

An Astro page is a component. It starts with a code fence made of two --- lines, holds JavaScript, and ends with a template that looks like HTML.

An index.astro source on the left, and the dist folder that astro build writes on the right.
An index.astro source on the left, and the dist folder that astro build writes on the right.

The Astro docs say the code in the fence runs at build time only and never reaches the browser. If you paste an .astro file into a browser, you see the fence and the braces as text.

The template does allow expressions in curly braces, and .map() to repeat an element:

---
const items = ["Dog", "Cat", "Platypus"];
---
<ul>
  {items.map((item) => (
    <li>{item}</li>
  ))}
</ul>

Astro also accepts .html files in src/pages. The docs warn that some Astro features do not work in them, so .astro is the usual choice.

Scripts: three kinds

By default an Astro component sends no JavaScript to the browser. Interactivity comes from script tags, and the tag decides what happens to your code.

The code fence, a plain script tag and a script tag with is:inline, and what Astro does with each.
The code fence, a plain script tag and a script tag with is:inline, and what Astro does with each.

A plain <script> is processed: Astro bundles its imports and makes it a module. Small scripts are embedded in the HTML, so a larger one may become a separate file. A script with is:inline is rendered exactly as you wrote it.

For a single file, use is:inline. It also suits scripts from a CDN.

Astro calls an interactive piece on an otherwise static page an island. For a UI framework component, a directive such as client:visible loads its JavaScript only when it scrolls into view. The example below does the same idea in plain JavaScript.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>One island</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .scroller { height: 300px; overflow-y: auto; border: 1px solid #d5d9e0; border-radius: 12px; background: #fff; padding: 14px; }
  .block { padding: 12px; border-radius: 8px; background: #eef1f5; margin-bottom: 12px; font-size: 14px; }
  .spacer { height: 260px; }
  .island { border: 2px dashed #9aa3b2; border-radius: 10px; padding: 12px; }
  .island.live { border-color: #0f5132; background: #f4fbf6; }
  button { font: inherit; padding: 8px 14px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  #status { font-size: 13px; margin: 0 0 8px; color: #6b7280; }
  .live #status { color: #0f5132; font-weight: 600; }
</style>
</head>
<body>
<div class="scroller">
  <div class="block">Static HTML. No script touches this block.</div>
  <div class="block">Scroll down. The island below wakes up when it comes into view.</div>
  <div class="spacer"></div>
  <div class="island" id="island">
    <p id="status">Static: the button does nothing yet</p>
    <button id="count" type="button">Clicked 0 times</button>
  </div>
  <div class="block" style="margin-top:12px">More static HTML under the island.</div>
</div>

<script>
  const island = document.getElementById('island');
  const status = document.getElementById('status');
  const btn = document.getElementById('count');
  let hydrated = false, n = 0;

  // Hydrate: attach behaviour only to this one element, once it is visible
  function hydrate() {
    if (hydrated) return;
    hydrated = true;
    island.classList.add('live');
    status.textContent = 'Live: JavaScript is attached here only';
    btn.addEventListener('click', () => { n++; btn.textContent = 'Clicked ' + n + (n === 1 ? ' time' : ' times'); });
  }

  new IntersectionObserver((entries, obs) => {
    if (entries.some((e) => e.isIntersecting)) { hydrate(); obs.disconnect(); }
  }, { root: document.querySelector('.scroller') }).observe(island);
</script>
</body>
</html>
Static HTML around one island. Scroll down and the island wakes up. Then click its button.

Styles: inline or linked

Astro scopes a component's <style> to that component. In the build, the build.inlineStylesheets option decides where the CSS goes. The default, auto, inlines stylesheets under a size limit and links the rest as files.

A page that links a missing CSS file looks unstyled. The same page with inlined styles works alone.
A page that links a missing CSS file looks unstyled. The same page with inlined styles works alone.

To keep all the CSS inside index.html, set the option to always:

// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  build: { inlineStylesheets: "always" },
});

Images are separate. Use an inline SVG or a data: URI for any picture that must travel inside the file.

A finished example: data in, HTML out

This is the whole idea in one screen. The left side plays the code fence: a list of titles and a .map(). The right side is all the browser receives.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Template to output</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  @media (max-width: 560px) { .cols { grid-template-columns: 1fr; } }
  .pn { background: #fff; border: 1px solid #e1e4ea; border-radius: 12px; padding: 12px; min-width: 0; }
  h2 { font-size: 13px; margin: 0 0 8px; letter-spacing: .3px; color: #6b7280; text-transform: uppercase; }
  .tag { display: inline-block; font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 99px; margin-bottom: 8px; }
  .src .tag { background: #fde2da; color: #9a3412; }
  .out .tag { background: #d6f2df; color: #0f5132; }
  textarea { width: 100%; height: 86px; box-sizing: border-box; font: 13px/1.4 ui-monospace, Consolas, monospace; border: 1px solid #d5d9e0; border-radius: 8px; padding: 8px; }
  #page ul { margin: 0; padding-left: 20px; }
  pre { margin: 10px 0 0; padding: 10px; background: #0f1623; color: #d7e0f0; border-radius: 8px; font-size: 12px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
  #count { font-size: 13px; color: #374151; margin: 8px 0 0; }
</style>
</head>
<body>
<div class="cols">
  <div class="pn src">
    <span class="tag">BUILD TIME (the code fence)</span>
    <h2>One title per line</h2>
    <textarea id="items" aria-label="Titles">Install Astro
Write a page
Run the build</textarea>
    <pre>const items = lines;
items.map(t =&gt; `&lt;li&gt;${t}&lt;/li&gt;`)</pre>
  </div>
  <div class="pn out">
    <span class="tag">WHAT THE BROWSER GETS</span>
    <h2>The page</h2>
    <div id="page"></div>
    <pre id="html"></pre>
    <p id="count"></p>
  </div>
</div>

<script>
  const input = document.getElementById('items');
  const page = document.getElementById('page');
  const html = document.getElementById('html');
  const count = document.getElementById('count');

  function esc(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

  function build() {
    // The "frontmatter" step: data in, finished HTML out
    const items = input.value.split('\n').map((s) => s.trim()).filter(Boolean);
    const out = '<ul>\n' + items.map((t) => '  <li>' + esc(t) + '</li>').join('\n') + '\n</ul>';
    page.innerHTML = out;
    html.textContent = out;  // shown as text so you can see the HTML itself
    count.textContent = items.length + ' list item' + (items.length === 1 ? '' : 's') + ' in the output. The code on the left is not in it.';
  }

  input.addEventListener('input', build);
  build();
</script>
</body>
</html>
Edit the titles on the left. The generated HTML on the right is the only thing that ships.

Notice what the output does not contain: the array, the loop or the template. Astro ran them once, at build time, and wrote the result.

From a project to one file

  1. Run npm run build. Astro writes static files to the dist folder.
  2. Set build.inlineStylesheets to always in astro.config.mjs.
  3. Write scripts as <script is:inline>.
  4. Open dist/index.html as text. Look for link or script tags that point to /_astro/.
  5. Paste the page into a NOS document and share it.

npm run dev starts the dev server, on port 4321 unless you change it. npm run preview serves the built site so you can test it before sharing.

For the page to be sized correctly on a phone, check that the layout includes the viewport meta tag.

When it does not work

What you see Cause Fix
Code and --- lines show as text An .astro file was pasted, not the built HTML Build, then use dist/index.html
The page is unstyled The CSS is a linked file that was not pasted Set inlineStylesheets to always
The button does nothing The script is a separate file under /_astro/ Use is:inline
A CORS error when opening from disk Module script files can hit CORS errors on file: URLs Use npm run preview, or inline the script
Pictures are broken Images are files in /_astro/ Inline SVG or a data: URI
A path starting with / finds nothing The path starts at the site root, not at your file Use paths relative to the file, or inline
A React component stays static No client directive on it Add client:load, or use plain JavaScript

More on missing styles is in HTML not loading CSS. Astro is one of several tools in static site generators.

An Astro project is source code. The people you send it to would need Node.js and a build step before they could see anything. A single built HTML file skips that.

Paste the page into a NOS document and choose Create share link. HTML to link walks through it. The page renders as written and its scripts run, so the people you send it to can click the button themselves.

If you change the code later, the same link shows the new version.

In NOS, images from other sites, iframes, fetch calls to other sites and form posts to a server are blocked. Inlining the CSS, the script and the images keeps the page working.

Scripts and styles from cdnjs.cloudflare.com, cdn.jsdelivr.net, cdn.tailwindcss.com, code.jquery.com and unpkg.com do load. Opening an HTML file on a phone covers the phone side.

Questions people ask

Can a browser open an .astro file?

No. An .astro file is a component: a code fence with JavaScript at the top, then an HTML-like template. Astro runs the code fence at build time and writes plain HTML. The browser opens that built HTML, not the .astro source.

Does Astro put JavaScript in the page?

Not by default. Astro components render to HTML and CSS only. JavaScript arrives when you add a script tag, or give a UI framework component a client directive such as client:load, which makes that one component interactive.

Can an Astro build be a single HTML file?

The build writes a dist folder. If you set build.inlineStylesheets to always and write scripts with is:inline, the CSS and JavaScript sit inside index.html. Images and other assets stay separate files unless you inline them yourself.

Do I need Node.js to use Astro?

Yes, to create and build an Astro project. The install guide lists Node.js as a requirement and gives the minimum version. The pages it builds need no Node.js: they are static files any browser can open.

Is Astro free to use?

Astro is open source under the MIT licence, according to the LICENSE file in its GitHub repository.

Keep reading