Astro Syntax Deep Dive
Expressions: { } drops into JavaScript
Section titled “Expressions: { } drops into JavaScript”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.
Conditionals
Section titled “Conditionals”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.
Fragments: grouping without a wrapper
Section titled “Fragments: grouping without a wrapper”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:
---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"><img src=x onerror=alert('xss')></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:
---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.
Spread attributes
Section titled “Spread attributes”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>Dynamic tag names
Section titled “Dynamic tag names”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
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:
---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:
<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:
<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.)
HTML attribute names, not JSX names
Section titled “HTML attribute names, not JSX names”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:
<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:
---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>Astro.self: recursive components
Section titled “Astro.self: recursive components”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:
---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.
Astro.slots.has and Astro.slots.render
Section titled “Astro.slots.has and Astro.slots.render”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:
---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} />Reading AI-generated code
Section titled “Reading AI-generated code”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:htmlwithout having been sanitized — a comment body, a username, anything that reached the server from a request? Proven above:set:htmlemits 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 realCannot find nameerror), not shown literally. - Is
classwritten asclassName, orforashtmlFor— 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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| 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 |
Common Misconceptions
Section titled “Common Misconceptions”- “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:htmlonly accepts a plain string.” Proven above: it also accepts aPromise<string>and aResponse, 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 usesis:raw).
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('<img src=x onerror=alert('xss')>'); });});