astro-marko

Use Marko 6 components in Astro — server-rendered by default, hydrated as islands when you need interactivity.

$npm create astro@latest
$npx astro add astro-marko

Why Marko + Astro?

Marko 6 is a lightweight, compiler-first framework for building dynamic, reactive user interfaces. It analyzes your code at compile time to determine precisely when and how state updates should run, enabling highly efficient, fine-grained DOM updates with minimal client-side overhead.

Astro takes a content-first approach. MDX is a first-class citizen, so you can seamlessly blend Markdown and components in a single file—just like this page. With Content Collections, you get automatic TypeScript type safety across all your content, whether it’s Markdown, MDX, or structured data. Astro also provides the essentials for content-driven sites out of the box: optimized images, flexible deployment adapters, and integrations for features like sitemaps and RSS feeds.

Marko fits naturally alongside Astro. Use Astro to structure your content and pages, and bring in Marko when you need precise, high-performance interactivity.

Setup

npx astro add astro-marko

This installs astro-marko and its peer dependencies (@marko/vite, marko) and adds the integration to your astro.config.mjs automatically:

import { defineConfig } from "astro/config";
import marko from "astro-marko";

export default defineConfig({
  integrations: [marko()],
});

Note: @marko/vite@6.x requires Vite 8, which conflicts with Astro 6’s bundled Vite 7. astro add will pin to @marko/vite@^5 automatically until Astro upgrades to Vite 8.

Demo: server-rendered component with slots

Marko components can be rendered entirely on the server. Slot content passed from Astro is available in the template as HTML strings — the default slot maps to input.content, and named slots keep their name. Use Marko’s $!{} syntax to render the HTML unescaped.

Card.marko

export interface Input {
  title?: string;
  content?: string;
  footer?: string;
}

<div class="card">
  <h3>${input.title}</h3>
  <if=input.content>
    <div class="card-body">$!{input.content}</div>
  </if>
  <if=input.footer>
    <footer class="card-footer">$!{input.footer}</footer>
  </if>
</div>

Page.astro

---
import Card from "./Card.marko";
---

<Card title="Card.marko">
  <p>This is the <strong>default slot</strong> — passed as input.content.</p>
  <p slot="footer">This is a <em>named slot</em> — passed as input.footer.</p>
</Card>
Live demo

Card.marko

This is the default slot — passed as input.content.

This is a named slot — passed as input.footer.

Demo: interactive island

Add a client:* directive to hydrate a Marko component as an island. Marko’s reactive local state works out of the box — no stores or signals needed for component-local interactivity.

Counter.marko

<let/count=0/>

<div class="counter">
  <span>${count}</span>
  <button on-click() { count-- }></button>
  <button on-click() { count = 0 }>Reset</button>
  <button on-click() { count++ }>+</button>
</div>

Page.astro

---
import Counter from "./Counter.marko";
---

<Counter client:load />

All of Astro’s client directives are supported: client:load, client:idle, client:visible, client:media, and client:only.

Live demo
0

Typed props

Per-component Input type inference is available as an opt-in feature. When enabled, importing a .marko file resolves to its specific Input interface rather than a generic fallback — giving you autocomplete and type errors for missing required props.

Enable it in your config:

import { defineConfig } from "astro/config";
import marko from "astro-marko";

export default defineConfig({
  integrations: [marko({ dts: true })],
});

On first run, the integration will:

  1. Check if allowArbitraryExtensions: true exists in your tsconfig.json and, if not, add it. If that addition is made, you will see a logged message prompting you to restart your editor’s TypeScript server. You can avoid this step by manually adding it to your tsconfig.json before running your server for the first time.
  2. Add **/*.marko.d.ts to your .gitignore
  3. Generate a .marko.d.ts sidecar file next to each .marko component in your project

The sidecar files are what make typed imports work. TypeScript 5.0+ checks for foo.marko.d.ts before falling back to the wildcard *.marko declaration, so each component gets its own type. For components that export an Input interface, the sidecar surfaces it:

// counter.marko.d.ts (generated — do not edit)
/// <reference types="marko" />
export interface Input {
  initialCount: number;
}
declare const template: Marko.Template<Input> &
  ((input: Input) => Marko.RenderedTemplate);
export default template;

Sidecars are regenerated on every dev server start, and updated in real time via Vite’s HMR when you edit a .marko file. They should not be committed to version control — the integration handles the .gitignore entry automatically.

Note: After the first run you must restart your editor’s TypeScript server once (VS Code: Cmd+Shift+P → “TypeScript: Restart TS Server”). To skip this step, add "allowArbitraryExtensions": true to your tsconfig.json under compilerOptions before starting the dev server — the integration will detect it’s already set and no restart will be needed.

Notes & open questions

This integration is an early alpha. A few things to be aware of:

@marko/vite version pin

You must use @marko/vite@^5 with Astro 6. @marko/vite@6.x declares vite@^8 as a peer dependency, which conflicts with Astro 6’s bundled Vite 7 — npm will refuse the install. The pin will be lifted when Astro upgrades to Vite 8.

Island hydration

client:* islands use Marko’s mount() API, which builds a fresh reactive DOM on the client rather than reconciling against the server-rendered markup. In practice this means the server-rendered content is completely replaced on hydration. The feasibility of true SSR reconciliation is still being explored.

TypeScript

Importing .marko files in .astro or .ts files is typed automatically via an ambient *.marko module declaration injected into your project. For full per-component Input type inference, see the Typed props section above.