Type something to search...
How to pass data from Astro to a client-side script?

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 with Number() and booleans with a comparison such as === 'true'.
  • Naming: data-user-id is read as dataset.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 as data-t="say &quot;hi&quot; &amp; <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 import npm 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="{&quot;published&quot;:[3,&quot;2026-10-24T00:00:00.000Z&quot;],&quot;tags&quot;:[5,[[0,&quot;astro&quot;],[0,&quot;react&quot;]]],&quot;stock&quot;:[4,[[1,[[0,&quot;mug&quot;],[0,12]]]]],&quot;homepage&quot;:[7,&quot;https://sajjad.me/blog&quot;]}"

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

MethodBest forBundled scriptKeeps types
data-* attributesA few simple values per elementYesNo, strings only
Custom element plus datasetReusable components used many timesYesNo, strings only
JSON script tagArrays, objects, configYesJSON types only
define:varsTiny inline snippetsNo (is:inline)JSON types only
Framework island propsReact, Vue or Svelte componentsYesYes, including Date, Map, Set
PUBLIC_ env variablesSite-wide constantsYesStrings

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.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper