
How to pass data from Astro to a client-side script?
To pass data from Astro to a client-side script, write the data into the HTML on the server and read it back in the browser. The recommended way is data-* attributes: render <div data-message={message}> in your template, then read element.dataset.message in a <script>. For larger or structured data, serialise it into a <script type="application/json"> tag with set:html={JSON.stringify(data)} and parse it with JSON.parse(). Astro also offers define:vars, which injects variables straight into a script, but it turns the script into an unbundled inline script, so it's best kept for small cases. Variables in the frontmatter never reach the browser on their own, because the frontmatter only runs on the server.
This trips up nearly everyone at first. You fetch some data in the frontmatter, write a <script> below it, reference the variable, and it's undefined or a build error. In this article you'll learn why that happens, how to use each of the four ways to hand data to the browser, how they behave when a component is used several times, what happens to types like dates along the way, and how to avoid a security mistake that's easy to make with JSON.
Why Frontmatter Variables Aren't Available in Scripts
An Astro component has two very different parts. The frontmatter between the --- fences runs on the server, at build time or per request. The <script> tags are bundled into JavaScript modules that run later, in the visitor's browser, as explained in the post on the Astro component script.
---
// src/components/Broken.astro
const message = 'Hello from the server';
---
<button>Say hi</button>
<script>
// This does NOT work: `message` only existed on the server
document.querySelector('button')?.addEventListener('click', () => {
alert(message);
});
</script>
The bundled script has no access to anything in the frontmatter. It's a separate module that gets processed, bundled and deduplicated on its own. Once it's in the browser, the server's variables are long gone. The only bridge between the two is the HTML the server produces.
Method 1: data-* Attributes
data-* attributes are the simplest and most flexible approach. Render the values as attributes, then read them from dataset:
---
// src/components/Greeting.astro
interface Props {
name: string;
count?: number;
}
const { name, count = 0 } = Astro.props;
---
<div class="greeting" data-name={name} data-count={count}>
<button type="button">Say hello</button>
<output></output>
</div>
<script>
const widgets = document.querySelectorAll<HTMLElement>('.greeting');
widgets.forEach((widget) => {
const name = widget.dataset.name ?? 'friend';
const count = Number(widget.dataset.count);
const button = widget.querySelector('button');
const output = widget.querySelector('output');
button?.addEventListener('click', () => {
if (output) output.textContent = `Hello, ${name}! You have ${count} new messages.`;
});
});
</script>
Use it twice with different props:
---
// src/pages/index.astro
import Greeting from '../components/Greeting.astro';
---
<Greeting name="Ada" count={3} />
<Greeting name="Linus" count={7} />
This is the built HTML, which shows how it works:
<div class="greeting" data-name="Ada" data-count="3"><button type="button">Say hello</button><output></output></div><script type="module">document.querySelectorAll(`.greeting`).forEach(e=>{...});</script><div class="greeting" data-name="Linus" data-count="7"><button type="button">Say hello</button><output></output></div>
The script appears once, even though the component was used twice, because Astro deduplicates processed scripts. That's why the script uses querySelectorAll() and loops over every instance, reading each one's own attributes. Clicking both buttons in Chrome produced:
Hello, Ada! You have 3 new messages.
Hello, Linus! You have 7 new messages.
A few things to know about data-* attributes:
- Everything becomes a string:
data-count={3}arrives as"3". Convert numbers withNumber()and booleans with a comparison such as=== 'true'. - Naming:
data-user-idis read asdataset.userId; dashes become camelCase. - Escaping is automatic: Astro escapes attribute values, so quotes and ampersands are safe. A value of
say "hi" & <b>was rendered asdata-t="say "hi" & <b>".
Method 2: Custom Elements with dataset
The Astro docs recommend pairing data-* attributes with a custom element. The browser calls connectedCallback() once per element, so you can write code for one instance at a time and use this.querySelector() instead of searching the whole page:
---
// src/components/AstroGreet.astro
const { message = 'Welcome, world!' } = Astro.props;
---
<astro-greet data-message={message}>
<button>Say hi!</button>
</astro-greet>
<script>
class AstroGreet extends HTMLElement {
connectedCallback() {
const message = this.dataset.message;
const button = this.querySelector('button');
button?.addEventListener('click', () => {
alert(message);
});
}
}
customElements.define('astro-greet', AstroGreet);
</script>
This scales better than the loop in Method 1. It also handles elements added to the page later, because the browser upgrades any <astro-greet> whenever it appears. Custom elements are covered in more depth in adding interactivity without a framework.
Method 3: A JSON Script Tag
When the data is an array of objects or a nested config, attributes get awkward. Serialise the whole thing into a non-executing script tag instead:
---
// src/components/PriceChart.astro
const prices = [
{ month: 'Jan', value: 12 },
{ month: 'Feb', value: 18 },
{ month: 'Mar', value: 9 },
];
const config = { currency: 'GBP', updatedAt: new Date('2026-10-01') };
const json = JSON.stringify({ prices, config }).replace(/</g, '\\u003c');
---
<price-chart>
<script type="application/json" set:html={json}></script>
<ul></ul>
</price-chart>
<script>
type ChartData = {
prices: { month: string; value: number }[];
config: { currency: string; updatedAt: string };
};
class PriceChart extends HTMLElement {
connectedCallback() {
const json = this.querySelector('script[type="application/json"]');
const data: ChartData = JSON.parse(json?.textContent ?? '{}');
const list = this.querySelector('ul');
const format = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: data.config.currency,
});
for (const { month, value } of data.prices) {
const item = document.createElement('li');
item.textContent = `${month}: ${format.format(value)}`;
list?.append(item);
}
console.log(typeof data.config.updatedAt, data.config.updatedAt);
}
}
customElements.define('price-chart', PriceChart);
</script>
A <script> with type="application/json" isn't executed by the browser, and because it has an attribute, Astro leaves it alone instead of bundling it. set:html writes the JSON in without escaping it as text. In the browser, the list rendered as:
Jan: £12.00
Feb: £18.00
Mar: £9.00
And the console showed what happened to the date:
string 2026-10-01T00:00:00.000Z
JSON.stringify() turns a Date into an ISO string, and JSON.parse() doesn't turn it back. The same applies to Map, Set and other non-JSON types. Convert them yourself on the client, for example with new Date(data.config.updatedAt). That's why the ChartData type above declares updatedAt as a string.
Escape the JSON to Avoid Script Injection
Notice the .replace(/</g, '\\u003c') in the frontmatter. It matters. set:html doesn't escape anything, and JSON.stringify() doesn't escape </script>. If any value in the data contains that sequence, for example a product title or comment from a user, it closes your JSON tag early and whatever follows runs as code.
I tested this with a title of Bad </script><script>alert(1)</script>. Without the replacement, the built HTML was:
<script type="application/json" id="raw">{"title":"Bad </script><script>alert(1)</script>"}</script>
When I loaded it in Chrome, the alert fired. With the replacement, the output became:
<script type="application/json" id="safe">{"title":"Bad </script><script>alert(1)</script>"}</script>
No alert fired, and JSON.parse() returned the original string unchanged, because < is just JSON's escape for <. Always escape JSON you put in a script tag unless every value is fully under your control. The wider risks of set:html are covered in using set:html safely.
Method 4: define:vars
define:vars passes frontmatter variables directly into a script:
---
// src/components/Analytics.astro
const siteId = 'site-123';
const debug = true;
---
<script define:vars={{ siteId, debug }}>
if (debug) console.log('Tracking site', siteId);
</script>
It looks like the easiest option, but look at the output:
<script>(function(){const siteId = "site-123";
const debug = true;
if (debug) console.log('Tracking site', siteId);
})();</script>
Astro wrapped the code in a function and declared each variable with its JSON-serialised value. Because the script has to re-run with different values for every component instance, using define:vars on a script implies is:inline. That means:
- No bundling or TypeScript: you can't
importnpm packages or local modules, and the code is output as written. - No deduplication: the script is repeated in the HTML every time the component renders.
- Runs in place: it executes where it appears, not as a deferred module.
define:vars is fine for a small snippet that genuinely needs to be inline, such as configuring a third-party analytics tag. For anything that imports code or appears many times on a page, use data-* attributes. define:vars on <style> tags is a different story, because it doesn't imply is:inline there; that's covered in using define:vars.
Passing Data to Framework Islands
If the client-side code is a React, Vue or Svelte component, you don't need any of this: pass props, and Astro handles serialisation for you. It also preserves more types than JSON does. A hydrated island's props are stored in its props attribute in a format that tags each value with its type. For a React component given a Date, a Set, a Map and a URL, the built HTML contained:
props="{"published":[3,"2026-10-24T00:00:00.000Z"],"tags":[5,[[0,"astro"],[0,"react"]]],"stock":[4,[[1,[[0,"mug"],[0,12]]]]],"homepage":[7,"https://sajjad.me/blog"]}"
In the browser, each prop arrived as a real Date, Set, Map and URL. Functions still can't be passed. This is essentially the data-* approach done for you, which the Astro docs point out: <astro-island> is itself a custom element reading its data from an attribute.
Public Environment Variables
Values that are the same for every page, like an API base URL, don't need to go through the HTML at all. Environment variables prefixed with PUBLIC_ can be used directly in processed scripts:
<script>
console.log(import.meta.env.PUBLIC_API_URL);
</script>
With PUBLIC_API_URL=https://api.example.com in .env, the built script contained the literal value https://api.example.com. Since Astro 6, import.meta.env values are always inlined at build time. Never put secrets in PUBLIC_ variables, because they end up in the JavaScript anyone can read.
Which Method to Use
| Method | Best for | Bundled script | Keeps types |
|---|---|---|---|
data-* attributes | A few simple values per element | Yes | No, strings only |
Custom element plus dataset | Reusable components used many times | Yes | No, strings only |
| JSON script tag | Arrays, objects, config | Yes | JSON types only |
define:vars | Tiny inline snippets | No (is:inline) | JSON types only |
| Framework island props | React, Vue or Svelte components | Yes | Yes, including Date, Map, Set |
PUBLIC_ env variables | Site-wide constants | Yes | Strings |
FAQ: Passing Data to Client-Side Scripts in Astro
The frontmatter runs on the server and the script runs in the browser as a separate bundled module. Variables don't cross that boundary. Write the value into the HTML, for example as a data attribute, and read it in the script.
Not bad, but limited. On a script it implies is:inline, so the script isn't bundled, can't import modules and is repeated for every component instance. Data attributes are usually the better choice.
Serialise it with JSON.stringify into a script tag with type="application/json" using set:html, escape any less-than characters, then read it with JSON.parse in your script. For a few values, separate data attributes are simpler.
JSON has no date type, so JSON.stringify turns dates into ISO strings and JSON.parse leaves them as strings. Recreate the date with new Date(value) in the browser. Framework island props are the exception, because Astro restores dates there.
Astro escapes attribute values, so quotes and ampersands in the data can't break out of the attribute. Be careful in your script, though: inserting the value with innerHTML would reintroduce the risk, so use textContent.
Not directly. Astro.props is only available in the frontmatter on the server. Render the props you need into data attributes, and the script can read them from there.
Conclusion
Astro keeps server code and browser code strictly apart, so the only way to move data from one to the other is through the HTML. For most cases, render values into data-* attributes and read them with dataset, ideally inside a custom element so each instance handles itself. For structured data, use a JSON script tag, and always escape < so user content can't break out of it.
Reach for define:vars only for small inline scripts, since it gives up bundling and deduplication. When the client code is a framework component, just pass props and let Astro serialise them, with dates, maps and sets intact. With those patterns, your scripts get exactly the data they need without anything leaking from the server that shouldn't.


