Server-Only & Scripts
This is the concept that surprises React developers most. In React, every component you write ships JavaScript to the browser — even a component that just renders a heading. Astro inverts this default: a plain .astro component produces zero JavaScript.
React always ships JS — Astro does not
Section titled “React always ships JS — Astro does not”// Heading.jsx — React// This component ships JS to the browser.// React needs a runtime to mount it, diff it,// and re-render it if props change.export default function Heading({ title }) { return <h1>{title}</h1>;}
// React also ships event-wiring, reconciler, etc.// even when none of that is needed here.---// Heading.astro — Astro// This compiles to:// <h1>My Title</h1>// No JavaScript. No runtime. No hydration.const { title } = Astro.props;---<h1>{title}</h1>The Astro compiler turns Heading.astro into a function that returns an HTML string. The browser receives that string. Done. No script tags, no React runtime, nothing.
What you cannot do in a plain .astro component
Section titled “What you cannot do in a plain .astro component”Because the frontmatter runs at build/server time and the template produces static HTML, you cannot:
- Call
useState,useReducer, or any React hook. - Attach event listeners in the frontmatter (no
onClickthat runs in the browser). - Read
window,document, orlocalStoragein the frontmatter (they do not exist at build time). - Make the UI react to user input — that requires an island (covered in the next module).
Adding vanilla browser JS with a script tag
Section titled “Adding vanilla browser JS with a script tag”When you need a small amount of browser JavaScript — a toggle, a counter, an analytics event — Astro lets you include a <script> tag directly in the template. Astro bundles and deduplicates these scripts automatically.
// Counter.jsx — React// Ships React + hydration + component JSimport { useState } from 'react';
export default function Counter() { const [count, setCount] = useState(0); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}> Increment </button> </div> );}---// Counter.astro — vanilla JS via <script>// Ships only the tiny script below — no framework runtime---<div> <p id="count">Count: 0</p> <button id="btn">Increment</button></div>
<script> let count = 0; const display = document.getElementById('count'); const btn = document.getElementById('btn'); btn.addEventListener('click', () => { count++; display.textContent = 'Count: ' + count; });</script>The <script> tag in an Astro component is processed by Vite: it is bundled, tree-shaken, and injected once per page even if the component is used multiple times. For complex interactivity, use an island (a React, Preact, Svelte, or other framework component with a client:* directive).
Runnable example with a script
Section titled “Runnable example with a script”---
const items = ["Astro", "React", "TypeScript", "Vite"];
---
<html lang="en">
<head><meta charset="utf-8" /><title>Script demo</title></head>
<body style="font-family:sans-serif;padding:2rem">
<h1>Tech Stack</h1>
<ul id="list" style="padding-left:1.5rem">
{items.map((item) => <li>{item}</li>)}
</ul>
<button id="toggle" style="margin-top:1rem;padding:.5rem 1rem;cursor:pointer">
Toggle list
</button>
</body>
</html>
<script>
const list = document.getElementById('list');
const btn = document.getElementById('toggle');
btn.addEventListener('click', () => {
list.style.display = list.style.display === 'none' ? '' : 'none';
});
</script>