Skip to content
Astro as data 4 min read

Passing Data to the Client

Astro renders everything on the server by default, so any fetch you run in a component’s frontmatter happens once, at build time or on each request, and never ships to the browser. The challenge appears the moment you need an interactive island: a React, Vue, Svelte, or Solid component that hydrates in the browser and expects to receive that server-fetched data. This page covers the two ways to bridge that gap — passing props to islands and serializing data into the page — and the rules that govern what can safely cross the server-to-client boundary.

Why the boundary exists

A .astro component runs only on the server. Its frontmatter (the code between the --- fences) executes in a Node-like environment, has access to secrets and databases, and produces HTML. None of that frontmatter code is sent to the client. An island, by contrast, runs twice: once on the server to produce initial HTML, and again in the browser when it hydrates. For hydration to work, the browser needs the same props the server used — so Astro serializes those props into the page.

This is why the way you move data matters: data passed as island props must survive being turned into JSON and back.

Passing data via island props

The idiomatic approach is to fetch in the .astro parent and pass the result down as props to a framework component. Astro handles the serialization automatically.

---
// src/pages/dashboard.astro
import StatsWidget from "../components/StatsWidget.jsx";

const res = await fetch("https://api.example.com/stats");
const stats = await res.json();
---

<h1>Dashboard</h1>
<StatsWidget client:load stats={stats} />
// src/components/StatsWidget.jsx
import { useState } from "react";

export default function StatsWidget({ stats }) {
  const [active, setActive] = useState(stats[0]?.id);
  return (
    <ul>
      {stats.map((s) => (
        <li
          key={s.id}
          onClick={() => setActive(s.id)}
          aria-current={s.id === active}
        >
          {s.label}: {s.value}
        </li>
      ))}
    </ul>
  );
}

The stats array is fetched on the server, rendered into static HTML, and embedded into the page so the island can rehydrate with identical data — no second client-side fetch required. Drop the client:* directive and the same component renders to pure HTML with zero JavaScript.

Tip: Only pass the props the island actually needs. Every prop you hand a client:* component is serialized into the HTML payload, so passing a 2 MB blob “just in case” bloats the page for no benefit.

What can be serialized

Astro serializes island props to JSON-like data. That constrains what you can pass.

TypeCrosses to client?Notes
Strings, numbers, booleans, nullYesSafe and lossless
Plain objects and arraysYesNested values must also be serializable
DateYesAstro preserves Date instances
Map, Set, BigInt, RegExp, URLYesAstro uses a rich serializer that supports these
Functions, class instances with methodsNoMethods and prototypes are lost
Symbol, DOM nodes, streamsNoThrow or become undefined

Astro’s serializer is more capable than raw JSON.stringify, but the rule of thumb stands: pass data, not behavior. If you find yourself wanting to pass a function, move that logic into the island instead.

---
const user = {
  name: "Ada",
  joined: new Date("2015-03-01"),
  roles: new Set(["admin", "editor"]),
};
---
<ProfileCard client:visible user={user} />

Here joined arrives in the island as a real Date and roles as a real Set — no manual revival needed.

Serializing data into the page manually

When the consumer is hand-written client JavaScript (a <script> tag) rather than a framework island, pass data through define:vars or a JSON <script> tag.

---
const config = { apiBase: "/api", featureFlags: { beta: true } };
---

<!-- Inlines variables into the script's scope -->
<script define:vars={{ config }}>
  console.log("API base is", config.apiBase);
</script>

<!-- Or as structured JSON the client parses -->
<script type="application/json" id="config" set:html={JSON.stringify(config)} />
<script>
  const cfg = JSON.parse(document.getElementById("config").textContent);
  console.log(cfg.featureFlags.beta);
</script>

Output:

API base is /api
true

Warning: define:vars makes the script inline, which disables Astro’s script bundling and processing for that block. Use it for small config values, not for importing modules. Never serialize secrets (API keys, tokens) into client-visible scripts — anything in the HTML is public.

Avoiding double-fetching

A common mistake is fetching on the server and re-fetching the same endpoint inside the island’s useEffect. Because the server already passed the data as props, the island should treat props as the initial source of truth and only re-fetch when something changes (a user action, polling, etc.).

import { useState } from "react";

export default function StatsWidget({ stats: initial }) {
  const [stats, setStats] = useState(initial); // seed from server data
  // refresh only on demand, not on mount
  const refresh = async () => {
    const res = await fetch("/api/stats");
    setStats(await res.json());
  };
  return <button onClick={refresh}>Refresh ({stats.length})</button>;
}

Best practices

  • Fetch once in the .astro parent and pass results down as island props; let Astro handle serialization.
  • Pass plain, serializable data — strings, numbers, objects, arrays, Date, Map, Set — never functions or class instances.
  • Send only the fields an island needs; trim large payloads before they cross the boundary to keep HTML lean.
  • Seed island state from props and avoid re-fetching the same data on mount.
  • Keep secrets server-side; anything serialized into props or <script> tags is visible in the page source.
  • Prefer a <script type="application/json"> block over define:vars for larger structured data so Astro can still bundle your scripts.
Last updated June 14, 2026
Was this helpful?