Skip to content
Astro as templating 4 min read

Dynamic Expressions

Astro templates are HTML supercharged with JavaScript. Inside a component’s markup you can drop into JavaScript at any point using a single pair of curly braces — { ... } — and Astro will evaluate the expression at build time (or on each request in SSR) and render the result as text. Because this happens on the server, the values are baked into static HTML and ship with zero client-side JavaScript by default, which keeps Astro pages fast and lean.

The component script and the template

Every .astro file is split into two parts by the --- fence: the component script (frontmatter), which runs on the server, and the component template below it. Any variable you declare in the script is in scope in the template.

---
const name = "Ada";
const year = 2015;
const tags = ["astro", "ssg", "islands"];
---
<h1>Hello, {name}!</h1>
<p>Astro launched in {year}.</p>
<p>This page has {tags.length} tags.</p>

The braces are an expression slot: whatever JavaScript expression you place inside is evaluated and its result is interpolated into the output.

Output:

<h1>Hello, Ada!</h1>
<p>Astro launched in 2015.</p>
<p>This page has 3 tags.</p>

Expressions, not statements

What goes inside {} must be an expression — something that produces a value. Function calls, arithmetic, ternaries, template literals, and property access all work. Statements like if, for, or variable declarations do not belong inside braces; do that logic in the component script instead.

---
const price = 49;
const user = { name: "Grace", premium: true };
const now = new Date();
---
<p>Total: ${price * 1.2}</p>
<p>Welcome back, {user.name.toUpperCase()}.</p>
<p>Plan: {user.premium ? "Premium" : "Free"}</p>
<p>Rendered at {now.getFullYear()}.</p>

Tip: If an expression starts getting long or hard to read, hoist it into a named const in the component script. The template stays declarative and the logic stays testable.

What renders and what doesn’t

Astro follows JSX-like rules for what each value type produces. Strings and numbers render as text. Booleans, null, and undefined render nothing — which is handy for conditional output. Arrays are flattened and each item rendered in order.

Value in {}Renders as
"hello" / 42The text hello / 42
true / falseNothing
null / undefinedNothing
["a", "b"]ab (each item rendered)
<p>Hi</p> (element)The HTML element
{ a: 1 } (plain object)[object Object] — avoid
---
const items = ["First", "Second", "Third"];
const empty = null;
---
<ul>{items.map((item) => <li>{item}</li>)}</ul>
<p>{empty}</p>

The {empty} paragraph renders as an empty <p></p> because null produces no text. This makes patterns like {user && <Greeting name={user.name} />} clean and safe.

HTML is escaped by default

Astro escapes interpolated strings to protect against injection. If title contains <script>, it is rendered as literal text, not executed.

---
const userInput = "<img src=x onerror=alert(1)>";
---
<p>{userInput}</p>

Output:

<p>&lt;img src=x onerror=alert(1)&gt;</p>

When you genuinely need to render trusted raw HTML (for example, sanitized markdown), use the set:html directive instead of an expression. Reserve it for content you control or have sanitized.

---
const trusted = "<strong>Bold</strong> and safe";
---
<div set:html={trusted} />

Warning: set:html bypasses escaping. Never pass unsanitized user input to it — that reopens the XSS hole that plain {} interpolation closes for you.

Calling functions and using imports

Because the script runs on the server, you can import helpers and call them directly in expressions. This is ideal for formatting, since the work happens at build time and never reaches the browser.

---
import { format } from "date-fns";

const published = new Date("2026-06-14");
function readingTime(words: number): string {
  return `${Math.ceil(words / 200)} min read`;
}
---
<time>{format(published, "MMMM d, yyyy")}</time>
<span>{readingTime(840)}</span>

Output:

<time>June 14, 2026</time>
<span>5 min read</span>

Fragments for multiple roots

When an expression returns several elements, wrap them in a fragment (<Fragment> or the shorthand <>...</>) so there is a single root without adding a wrapper element to the DOM.

---
const rows = [
  { label: "Name", value: "Astro" },
  { label: "Type", value: "Framework" },
];
---
<dl>
  {rows.map((row) => (
    <Fragment>
      <dt>{row.label}</dt>
      <dd>{row.value}</dd>
    </Fragment>
  ))}
</dl>

Best practices

  • Keep expressions to a single value-producing operation; move branching and loops with side effects into the component script.
  • Hoist complex logic into named consts or small functions for readability and reuse.
  • Lean on null/undefined/false rendering as nothing for clean conditional output instead of fragile string concatenation.
  • Trust the default escaping for any dynamic text — only reach for set:html with content you have sanitized.
  • Do formatting (dates, currency, reading time) in expressions so it runs on the server and ships zero JavaScript.
  • Use a fragment instead of a wrapper <div> when mapping returns sibling elements you do not want in the DOM.
Last updated June 14, 2026
Was this helpful?