Client-Side Scripts
Astro ships zero JavaScript to the browser by default — your .astro components render to static HTML at build time. When you genuinely need interactivity, you reach for a standard <script> tag. The difference from a plain HTML page is that Astro treats these scripts as first-class build inputs: it bundles them, processes their imports, applies TypeScript and optimizations, and scopes them to the page they appear on. This keeps your runtime lean while letting you opt into client behavior exactly where it is needed.
A first interactive script
Anywhere inside an .astro file’s template, drop a <script> tag with browser JavaScript. Astro detects it, processes it through its build pipeline, and injects the bundled result into the page.
---
// src/components/Counter.astro
---
<button id="increment" type="button">Count: 0</button>
<script>
const button = document.getElementById('increment');
let count = 0;
button?.addEventListener('click', () => {
count += 1;
button.textContent = `Count: ${count}`;
});
</script>
This script runs in the browser, not at build time. It is parsed, bundled, and emitted as a hashed .js asset that the browser loads as a module.
How Astro processes scripts
By default, a <script> tag is treated as a JavaScript module. Astro performs several steps that a raw HTML <script> never would:
- Bundles the script and any of its imports into optimized chunks.
- Resolves
importstatements, including npm packages and local files. - Compiles TypeScript and strips types.
- Injects the result into the page as
<script type="module">.
Because the output is an ES module, top-level code runs once, import works natively, and the script is deferred automatically — it executes after the HTML is parsed.
<script>
// TypeScript and npm imports both work here
import confetti from 'canvas-confetti';
document.querySelector('#celebrate')?.addEventListener('click', () => {
confetti({ particleCount: 120, spread: 70 });
});
</script>
Tip: Astro deduplicates and code-splits processed scripts across your site. If the same component appears on many pages, its bundled script is shared rather than duplicated, improving caching.
Default scoping and bundling
Scripts are processed and hoisted into the page <head> as bundled modules. Importantly, the JavaScript in a processed <script> is not automatically scoped the way <style> blocks are — it shares the global browser context. To target only the current component’s markup, select elements explicitly (by id, data-* attribute, or a class) rather than relying on automatic isolation.
A robust pattern is to scope your DOM queries to a container element:
---
// src/components/Tabs.astro
---
<div class="tabs" data-tabs>
<button data-tab="a">A</button>
<button data-tab="b">B</button>
</div>
<script>
document.querySelectorAll<HTMLElement>('[data-tabs]').forEach((root) => {
root.querySelectorAll<HTMLButtonElement>('button').forEach((btn) => {
btn.addEventListener('click', () => {
root.dataset.active = btn.dataset.tab;
});
});
});
</script>
This iterates every instance of the component on the page, so the behavior works correctly even when the component is reused multiple times.
Opting out of processing
Some scripts should be passed through untouched — for example, a third-party analytics snippet or an inline JSON-LD block. Add the is:inline directive and Astro will render the tag exactly as written, with no bundling, imports, or TypeScript.
<script is:inline>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
</script>
<script is:inline src="https://example.com/widget.js" async></script>
| Behavior | Processed <script> | <script is:inline> |
|---|---|---|
| Bundled & optimized | Yes | No |
import resolution | Yes | No |
| TypeScript support | Yes | No |
| Rendered verbatim | No | Yes |
| Type emitted | type="module" | As authored |
Warning:
is:inlineis required if you use any attribute other thansrc(such asasync,defer,type, ordata-*) on a<script>and still want it untouched, or if you reference a variable from the component frontmatter. Inline scripts cannot use ESMimport.
Passing data from the server to the client
Frontmatter runs at build time, so you cannot reference its variables directly inside a processed module script. Pass values through the DOM instead — typically with data-* attributes or define:vars on an inline script.
---
const userId = Astro.props.userId;
const config = { theme: 'dark', userId };
---
<div id="app" data-user-id={userId}></div>
<script>
const el = document.getElementById('app');
const id = el?.dataset.userId;
console.log('Loaded for user', id);
</script>
<!-- Or inject a serialized object directly -->
<script is:inline define:vars={{ config }}>
console.log(config.theme, config.userId);
</script>
Output:
Loaded for user 42
dark 42
When you need richer interactivity — reactive state, lifecycle, or component reuse — prefer a UI framework island with a client:* directive over hand-written scripts. Plain scripts are best for small, imperative DOM enhancements.
Best Practices
- Default to zero JS; add a
<script>only when a feature truly needs client behavior. - Let Astro process scripts so you get bundling, TypeScript, and
importsupport — avoidis:inlineunless you specifically need raw passthrough. - Scope DOM queries to a container (
data-*attribute orid) and usequerySelectorAll().forEach()so reused components each behave correctly. - Pass server data via
data-*attributes ordefine:vars, never by referencing frontmatter variables inside a module script. - Reserve
is:inlinefor third-party snippets, structured-data blocks, and scripts that must run exactly as authored. - For reactive, stateful UI, reach for a framework island with a
client:*directive instead of growing a vanilla script.