Skip to content

Astro Syntax Deep Dive

Anywhere in the template, { } embeds a JavaScript expression whose value is rendered. Variables from the frontmatter are in scope.

---
const name = "Ada";
const count = 3;
---
<h1>Hello, {name}</h1>
<p>You have {count} messages.</p>
<p>Total: {count * 2}</p>

Only expressions go inside { } — things that evaluate to a value. Statements (an if block, a for loop) belong in the frontmatter; in the template you express control flow as expressions, shown below.

Use the logical-AND idiom to render something only when a condition is true, and a ternary to choose between two branches:

---
const isLoggedIn = true;
const user = { name: "Ada", role: "admin" };
---
{isLoggedIn && <p>Welcome back, {user.name}</p>}
{user.role === "admin"
? <a href="/admin">Admin panel</a>
: <a href="/account">Your account</a>}

{cond && <div/>} renders the element when cond is truthy, and nothing otherwise. The ternary always renders one of the two branches.

There is no special loop directive — you map an array to markup, exactly like JSX:

---
const posts = [
{ id: 1, title: "First" },
{ id: 2, title: "Second" },
];
---
<ul>
{posts.map((post) => <li>{post.title}</li>)}
</ul>

Astro does not require a key prop the way React does — this is server rendering, there is no reconciliation. Map, return elements, done.

When you need to return several elements without adding a wrapping DOM node, use a Fragment. Astro supports both the named <Fragment> and the shorthand <>:

---
const items = ["a", "b"];
---
<Fragment>
<dt>Term</dt>
<dd>Definition</dd>
</Fragment>
{items.map((x) => (
<>
<span>{x}</span>
<br />
</>
))}

Fragments are also where set:* directives live when you have no element to attach them to (next).

Rendering raw HTML: set:html and set:text, proven

Section titled “Rendering raw HTML: set:html and set:text, proven”

By default, an expression that produces a string is escaped — Astro renders it as text, so <b>hi</b> shows the tags literally. That is safe. When you have a trusted HTML string you genuinely want rendered as markup (say, HTML from a CMS you control), use set:html:

src/components/SetHtmlDemo.astro
---
const untrusted = "<img src=x onerror=alert('xss')>";
---
<div id="raw" set:html={untrusted} />
<div id="safe" set:text={untrusted} />

set:html bypasses escaping — including for content you did not write yourself. Rendered through the Container API against the installed [email protected], with an XSS-shaped string that a real attacker-controlled field (a comment, a username, a search query) might contain:

<!-- id="raw" (set:html) -->
<div id="raw"><img src=x onerror=alert('xss')></div>
<!-- id="safe" (set:text) -->
<div id="safe">&lt;img src=x onerror=alert(&#39;xss&#39;)&gt;</div>

The id="raw" div emits the <img onerror=...> tag verbatim — a real XSS payload, executed the instant a browser parses it. set:text (the div below it, same input) escapes every character, so the tags show up as inert text. This is not a hypothetical: set:html is the injection point if its argument ever contains attacker-influenced content — treat it exactly like dangerouslySetInnerHTML in React or v-html in Vue. Only use it on content you control or that has been sanitized through a real HTML sanitizer.

set:html’s type is any, and its runtime behavior is more permissive than “a string” — verified directly against the installed compiler:

src/components/SetHtmlTypesDemo.astro
---
const promiseVal = Promise.resolve('<b>from a promise</b>');
const responseVal = new Response('<b>from a response</b>');
---
<div id="promise" set:html={promiseVal} />
<div id="response" set:html={responseVal} />
<div id="promise"><b>from a promise</b></div>
<div id="response"><b>from a response</b></div>

A Promise<string> is awaited before being injected, and a Response has its body read as text — both land as real, un-escaped markup. What it does not accept is a JSX/Astro element passed directly as the value — that fails at compile time, not runtime:

// @expect-error set:html cannot take a JSX expression directly — only a string, Promise<string>, or Response
---
---
<div set:html={<Fragment><b>from jsx</b></Fragment>} />

Proven against the installed compiler — a real parse failure, not a type warning:

Parse failure: Parse failed with 1 error:
Unexpected JSX expression
...${$$unescapeHTML(<Fragment>...
^

set:text forces the value to be treated as plain text (the safe default made explicit) — reach for it whenever you want to guarantee no HTML is ever emitted, even if the string later starts containing markup-looking characters.

When you have an object of attributes, spread it onto an element with {...obj} — handy for passing through props:

---
const attrs = { id: "cta", class: "btn", "data-track": "signup" };
---
<a href="/join" {...attrs}>Join</a>

You can compute which element to render. Assign a capitalized variable to a tag name and use it:

---
const level = 2;
const Heading = `h${level}`; // "h2"
---
<Heading>Dynamic heading</Heading> <!-- renders <h2>Dynamic heading</h2> -->

The variable must be capitalized so Astro treats it as a component/tag reference rather than a literal HTML tag.

flowchart LR
  expr["expression in curly braces"] --> eval["evaluated on the server"]
  eval --> esc["string values are escaped by default"]
  esc --> html["safe HTML output"]
  eval --> raw["set:html bypasses escaping for trusted HTML"]
  raw --> html
How a template expression becomes HTML

Astro 7’s compiler, proven wrong four ways

Section titled “Astro 7’s compiler, proven wrong four ways”

Astro 7 replaced the old Go-based compiler with a Rust compiler, and it is now the only compiler. It is faster, and it is stricter: the old compiler silently “corrected” broken HTML, the new one reports it as a build error. Four real failures, run through astro check against the installed [email protected] — not remembered, reproduced:

1. An unclosed tag:

// @expect-error unclosed <p> — Astro 6 silently closed it for you, Astro 7 fails the build
---
---
<p>Hello world<p>Hello world</p>
src/pages/unclosed.astro:3:2 - error ts(17008): HTML element 'p' has no corresponding closing tag.
<p>Hello world<p>Hello world</p>
~
src/pages/unclosed.astro:4:1 - error astro: Expected a closing tag but instead found the end of the file.

2. Elements closed in the wrong order (a <span> closed after its parent <div> instead of before it):

// @expect-error mis-nested closing tags
---
---
<div><span>oops</div></span>
src/pages/wrongorder.astro:3:24 - error ts(17002): Expected corresponding JSX closing tag for 'Fragment'.
<div><span>oops</div></span>
~~~~
src/pages/wrongorder.astro:3:7 - error ts(17008): HTML element 'span' has no corresponding closing tag.
<div><span>oops</div></span>
~~~~
src/pages/wrongorder.astro:3:16 - error astro: Expected a matching closing tag but instead found '</div>'.
<div><span>oops</div></span>
~~~~~~

3. A bare { in prose (not meant as an expression — a sentence that happens to use curly braces):

// @expect-error a `{` outside a fence is parsed as the start of an expression, not literal text
---
---
<p>Set theory uses { and } to denote a set.</p>
src/pages/curlytext.astro:3:22 - error ts(2304): Cannot find name 'and'.
<p>Set theory uses { and } to denote a set.</p>
~~~

4. An expression that “returns an object” — this one does not fail the build at all. It is valid JavaScript and valid Astro, and it renders exactly what String(obj) would:

src/components/ObjExprDemo.astro
---
const obj = { a: 1, b: 2 };
---
<p>{obj}</p>
<p>[object Object]</p>

This is a real footgun, just not a compile-time one: an object dropped into { } stringifies to the useless literal text [object Object] instead of erroring, so the mistake only shows up by eyeballing the rendered page (or a snapshot test). If you meant to render a field, write {obj.a}; if you meant JSON for debugging, write {JSON.stringify(obj)}.

The second Astro 7 change is whitespace. The compressHTML option now defaults to 'jsx', so whitespace between elements follows JSX rules — a newline between two inline elements no longer produces a visible space. Proven with the Container API, data-astro-source-* debug attributes stripped for readability:

src/components/WhitespaceDemo.astro
<span>hello</span>
<em>world</em>
<span>hello</span><em>world</em>

That renders as “helloworld”, no space — confirmed against the real output, not assumed. The fix is explicit, exactly as you would in React:

src/components/WhitespaceFixedDemo.astro
<span>hello</span>{" "}<em>world</em>
<span>hello</span> <em>world</em>

If a large existing template depends on the old behaviour, set compressHTML: true in astro.config.mjs to keep it while you migrate. New projects should just write {" "} where a space matters.

Try it: compile a real mistake in the browser. The four failures above are proven against the real Rust compiler via astro check — but that compiler can’t run in a browser (its only WASM build fails to even link when loaded from a CDN: LinkError: ... "napi_create_async_work": function import requires a callable). This playground instead loads the classic, browser-embeddable @astrojs/compiler package — the pre-7.0 Go compiler, still published for exactly this use case. It’s far more lenient (the unclosed/mis-nested tags above compile with zero diagnostics through it), but it still catches this real, version-independent Astro mistake — set:html on an element that still has child nodes:

set:html with leftover children

---
---
<div set:html={"<b>Overwritten</b>"}>
This text is ignored -- set:html replaces all children.
</div>

Click Compile: the diagnostics panel shows a real WARNING_SET_WITH_CHILDREN — “set:html directive will overwrite child nodes” — and the output panel shows the compiled JS, so you can see for yourself that the text never makes it into the rendered output. (Proven while building this playground: this diagnostic only fires for a text-node child like the one above — swap it for a nested element like <p>...</p> and, in this compiler build, the warning silently disappears. A real, if surprising, gap — not every “still has children” case is actually caught.)

A subtle but important difference from React: Astro templates use standard HTML attribute names. It is class, not className; for, not htmlFor. You are writing a superset of HTML, so the HTML names are the real ones.

Directives: is:raw, define:vars, class:list

Section titled “Directives: is:raw, define:vars, class:list”

is:raw tells the compiler to stop parsing the element’s children as Astro syntax at all — { } inside it is literal text, not an expression:

src/components/IsRawDemo.astro
<div is:raw>
{ this is not an expression, is:raw stops the compiler from parsing it }
<p>literal { braces } stay literal</p>
</div>

Proven output — the braces pass through untouched, and the nested <p> is not even parsed as an element:

<div>
{ this is not an expression, is:raw stops the compiler from parsing it }
<p>literal { braces } stay literal</p>
</div>

Reach for it when you’re emitting a code sample or a client-side templating snippet (Handlebars, Vue templates) that is full of { } you don’t want Astro to touch.

define:vars (introduced earlier for <style>) also works on other elements to inject frontmatter values as inline CSS custom properties — covered in depth for <style> in the next lesson.

class:list merges several class-name sources — arrays, objects ({ className: boolean }), conditional strings, and plain strings — into one class attribute, applied in addition to a static class:

src/components/ClassListDemo.astro
---
const isActive = true;
const size = 'lg';
---
<div class="base" class:list={['btn', { active: isActive, disabled: false }, size && `size-${size}`]}>x</div>

Proven merged output — disabled is dropped (its value is false), the falsy-guarded template string wins because size is truthy, and the static class="base" is preserved and merged in:

<div class="base btn active size-lg">x</div>

A component can render itself with <Astro.self>, passing new props each time — this is how you build a tree (nested comments, a recursive nav, a file browser) from one component definition:

src/components/SelfRecursion.astro
---
interface Props { depth: number }
const { depth } = Astro.props;
---
{depth > 0 ? (
<div class="node">
depth {depth}
<Astro.self depth={depth - 1} />
</div>
) : <div class="leaf">leaf</div>}

Rendered with depth={3} through the Container API — real nested output, one level per recursive call, bottoming out at the leaf branch:

<div class="node">depth 3<div class="node">depth 2<div class="node">depth 1<div class="leaf">leaf</div></div></div></div>

There is no built-in recursion limit beyond your own stack — guard the terminating condition (depth > 0 above) the same way you would in any recursive function, or a bad prop (say, a negative starting depth that never reaches your base case) recurses until the process gives up.

Beyond <slot /> in the template, Astro.slots is a frontmatter API: has(name) reports whether a slot received content, and render(name, args?) returns the slot’s content as an HTML string (awaiting it, since rendering is async) — useful when you need the markup as a value rather than something you drop straight into the template with <slot />. slots-and-composition (next lesson) uses render’s args parameter to build a function-as-slot (a “scoped slot”); here, the mechanics proven on their own:

src/components/SlotsHasRenderDemo.astro
---
const hasDefault = Astro.slots.has('default');
const rendered = await Astro.slots.render('default', ['scoped-arg']);
---
<p>has:{String(hasDefault)}</p>
<div id="rendered" set:html={rendered} />

A generated snippet with template expressions can look right and still be wrong in ways that only show up at runtime or under a specific input. Check for these:

  • Is a value ever passed straight into set:html without having been sanitized — a comment body, a username, anything that reached the server from a request? Proven above: set:html emits its argument verbatim, XSS payload included.
  • Does an expression render an object or array directly ({user} instead of {user.name})? It won’t error — it silently renders [object Object], proven above.
  • Does a { appear in plain prose text, outside a code fence and outside an intentional expression? It will be parsed as the start of an expression (proven above with the real Cannot find name error), not shown literally.
  • Is class written as className, or for as htmlFor — copied from a React example without translating to Astro’s HTML-native attribute names?
  • Does the snippet assume <span>/<em> (or similar inline elements) on separate lines will render with a space between them? Since Astro 7, compressHTML: 'jsx' means they won’t — proven above — unless {" "} is explicit.

🐛 Spot the bug

---
const comment = Astro.props.comment; // arbitrary user-submitted text
---
<div class="comment-body" set:html={comment} />
Show the answer

This looks like ordinary “render the CMS field” code, but comment here is described as user-submitted — the same category of input proven above to come through set:html completely unescaped. If comment ever contains <img src=x onerror=...> (or a <script> tag, or an onclick attribute), it executes in every visitor’s browser exactly as proven earlier in this lesson. The fix is either set:text={comment} (if it should never contain markup) or running comment through a real HTML sanitizer (e.g. sanitize-html or DOMPurify server-side) before it ever reaches set:html — never trust a raw string labeled “user-submitted” here.

Astro’s template layer is JSX’s expression rules bolted onto real HTML: { } is “drop into JavaScript,” everything else is HTML exactly as browsers already parse it. That means the compiler enforces HTML’s own well-formedness (proven above: unclosed and mis-nested tags are now build errors, not silently patched), and it means whitespace between elements follows JSX’s collapsing rules, not raw-HTML’s. set:html is the one deliberate escape hatch from “everything is escaped by default” — treat every value that reaches it as if it will be executed by a browser, because it will.

Escaped (default) set:html
Safety Safe by default — tags render as text Only as safe as the input — proven above with a real XSS payload
Use for Any value you didn’t personally author and vet as HTML Trusted HTML (a CMS field you sanitize, your own generated markup)
Accepts Any stringifiable value A string, a Promise<string> (auto-awaited), or a Response (body read as text) — not a JSX element directly
  • “An object dropped into { } will show an error so I’ll notice the mistake.” Proven above: it renders [object Object] at runtime, no build error at all.
  • “set:html only accepts a plain string.” Proven above: it also accepts a Promise<string> and a Response, though not a JSX/Astro element passed directly (a real, different compile error).
  • “Newlines between inline elements in HTML always render as a space, like they do in the browser normally.” Astro 7’s default compressHTML: 'jsx' collapses that whitespace — proven above, <span>hello</span>\n<em>world</em> renders “helloworld”, not “hello world”.
  • “A stray { in a sentence just prints literally, like it would in plain HTML.” Proven above: it’s parsed as the start of an expression and fails the build unless the content genuinely is one (or the element uses is:raw).
src/components/SetHtmlDemo.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect } from 'vitest';
import SetHtmlDemo from './SetHtmlDemo.astro';
describe('set:html vs set:text', () => {
it('set:html emits raw markup verbatim; set:text escapes the same input', async () => {
const container = await AstroContainer.create();
const html = await container.renderToString(SetHtmlDemo);
expect(html).toContain("<img src=x onerror=alert('xss')>");
expect(html).toContain('&lt;img src=x onerror=alert(&#39;xss&#39;)&gt;');
});
});
How do you render a list from an array in an Astro template?
What does set:html do, and what is the risk?
You want to return two sibling elements without adding a wrapper node. What do you use?
Which attribute name is correct in an Astro template?
Proven with the Container API against [email protected], what does this render as, and why?
---
---
<span>hello</span>
<em>world</em>
What happens when this builds and renders?
---
const obj = { a: 1, b: 2 };
---
<p>{obj}</p>
Astro's compiler reports Cannot find name 'and' on <p>Set theory uses { and } to denote a set.</p>. What actually caused this?