Distilled from the full Marko 6 docs (reference, explanation, guide, tutorial; Marko 5 / class API intentionally excluded). This file = rules agents get wrong + quick reference. Full detail lives in references/. When something here doesn't match observed behavior, the live docs win — Marko moves fast: index at https://markojs.com/llms.txt; any page fetches as markdown by appending .md (e.g. https://markojs.com/docs/reference/core-tag.md).
Marko 6 only — never write Marko 5
Reject on sight: class { onCreate/onMount... } blocks, $ const x = ... scriptlets, <if(cond)>/<for(x in y)> paren args, string handlers on-click("handleClick"), state/this.state, <include>/<invoke>, components/ discovery, Marko.Component types. Marko 6 equivalents: <let>/<const>/<script>/<lifecycle>, <if=cond>, <for|x| of=y>, onClick() {}, tags/ discovery, export interface Input.
Checklist for any new or edited .marko file
- Marko 6 syntax only (list above).
- State first: no
<script>/<lifecycle>where<let>/<const>/controllable natives (value:=) do the job;<script>only for genuine browser effects. - Handlers are functions (
onClick() {}/onClick=fn), never strings;(event, element)args, nocurrentTarget. -
export interface Inputdeclared; typecheck withmtc(@marko/type-check) —tscskips.markoand reports success. - Ship minimal client JS: prefer HTML/CSS (
<details>,<dialog>,:has,popover) over scripted behavior; only reactive parts ship anyway. - Keep components small; split by concern, not by technology (bundle size is unaffected by splitting).
- SSR-safe: nothing unserializable in
<let>;$signal/element refs only in<script>/<lifecycle>/handlers. - Immutable updates (
x = x.concat()),by=on reorderable<for>s,<show>vs<if>chosen deliberately.
| File | Read when |
|---|---|
references/language.md |
syntax: statements, attributes & shorthands, content, attr tags, tag vars/params, dynamic tags, concise mode, whitespace |
references/core-tags.md |
<if> <show> <for> <let> <const> <return> <script> <style> <define> <lifecycle> <id> <await> <try> + reactivity/scheduling model |
references/native-tags.md |
class=/style=/content=, event delegation, controllable value:=/checked:=/open:=, spread ownership |
references/typescript.md |
Input, Marko.* types, augmenting natives/globals/CSS, TS syntax in tags |
references/template-api.md |
render/mount, $global (serializedGlobals, cspNonce, renderId, runtimeId) |
references/custom-tags-styling.md |
tag discovery order, tags/ dirs, marko.json, styles, lazy with { load } |
references/patterns.md |
controllable components, nested state, serializable/immutable state, streaming, tooling |
Component architecture for this repo (Zag wiring, attr-tag APIs): skills/port-to-marko/references/marko-gotchas.md.
Mental model (10 lines)
- File = component.
input= attrs.tags/dirs auto-discover<tag-name>; PascalCase variables are tags; strings in<${...}>are always native elements. - Module statements only:
import,export,static,server,client. Everything else is template body (reactive). - Reactive sources: tag vars (
/x), tag params (|x|),input. Graph is compiled, not tracked at runtime. Expressions must be pure. <let>= state (assignment-reactive, immutable updates, serialized on SSR).<const>= derived (lazy, flush-time).static const= module constant.- Updates batch after a microtask. After
count++the<let>is new, every<const>/DOM is stale until flush. <script>= browser-only effect that re-runs on referenced tag var/param changes; cleanup via$signal.onabort.<lifecycle>=onMount/onUpdate/onDestroywith persistentthis.- Native tag var
<div/el/>is a getter function, browser-only, usable only after render. - Controllable everything:
value:=x≡value=x valueChange(v){x=v}; on member exprs ≡value=input.x valueChange=input.xChange.<let/x:=input.x>makes a component controllable. - Server/client boundary: only serializable plain data crosses; no class instances, DOM nodes, or foreign-module closures in state;
$globalstays server-side unless inserializedGlobals. - Zero-JS by default: only reactive parts ship to the browser; component granularity doesn't change bundle size.
Quick reference — the rules that bite
Syntax
- Unenclosed
>in attr value →value=(a > b).>=and=>OK. <div#id.cls.cls2/>shorthand;<Comp.x/>isclass="x", not property access →<${Comp.x}/>.- Bare root text in concise mode parses as tags →
-- text. Multi-line attrs: leading commas. - Attribute string is a JS literal;
aria-*enumerated attrs need strings:aria-pressed=on && "true". null|undefined|falseattrs skipped;0,"",NaNwritten. Text:null|undefined|false|""|NaN|0nskipped,0rendered.- Line break between two inline tags = no space. Put the space on the same line.
<if>/<else if=…>/<else>are direct siblings. Docs write<else if=cond>;<else-if=cond>also compiles on 6.3.34 (verified).<${input.content}/>forwards content; native tags also acceptcontent=input.contentor just<div ...input/>. Literal body (even a comment) overridescontent=.- A component's
contentattr must be the entirety of its default body — Marko setsinput.contentfrom a tag's non-attr-tag children automatically. Callers never write<@content>for it; the body goes in directly (params too:<Tag|params|>body</Tag>, not<@content|params|>). If a component needs two-or-more peer body-shaped slots, name them (e.g.main), nevercontent—contentimplies "the rest of the body" and a second same-shaped slot breaks that. - Repeated
<@item>→input.itemis first + iterable:<for|it| of=input.item>or[...input.item || []]. TypeMarko.AttrTag<{...}>. - Tag params:
<child|{ n }|>; args form<${input.content}(1,2)/>→|a, b|. Attrs or args, not both. Attr tags can't read params. - Closing tag name optional (
</>); dynamic tags close with</>.
Reactivity & timing
<let/x=input.x>does NOT follow laterinput.xchanges. Use<let/x:=input.x>(controllable) or readinput.xdirectly.- Mutation isn't reactive:
arr.push()❌ →arr = arr.concat();obj.k = v❌ →obj = { ...obj, k: v }. - In handlers recompute from
<let>s;<const>values and DOM are stale until flush. <if>destroys/recreates state;<show>keeps it mounted.<for ... by="id">to preserve state on reorder.$signalonly inside<script>/<lifecycle>/handlers (throws on server elsewhere). Capture it beforeawait.<lifecycle>: return an object fromonMount(bare fn cleanup is dropped); don't overwrite attribute props onthis.<return>: one per template/content, top-level; vary itsvalue=.
Native tags & events
- Handler
(event, element); noevent.currentTarget(delegated) — use 2nd arg or ref. Marko handlers run before native listeners; non-bubbling events only on the target. <input value:=n>gives strings →value:parseFloat:=n/valueChange(v){ n = +v }.<select value:=x>: every<option>needsvalue=; noselected=.style={}keys hyphen-case, no unit inference; conditional declarations at array level.- Spread owns attrs (removed when missing later); write fixed attrs after the spread.
<details open:=o>,<dialog open:=o>;showModal()doesn't fireopenChange.
Types
export interface Input {}(bareinterface= parse error).Marko.Body<[params], { value }>,Marko.AttrTag<T>,Marko.HTML.Buttonfor extending natives,Marko.Globalaugmentation,Marko.NativeTagsfor custom elements. Typecheck withmtc(@marko/type-check), nottsc.
Discovery & assets
- Only
tags/dirs are searched (upward); sibling files needimport. Packages export viamarko.jsonexports. Adjacentstyle.css/foo.style.cssauto-attached. <style>global, once;<style/styles>CSS Modules;${}inside<style>→ custom props, declaration values only, above the styled content.- Lazy:
import X from "<x>" with { load: "visible#id | idle?timeout=2000" }static string; facade tag to centralize.
Template API
T.render(input)consumed once:for await,.pipe(res),.toReadable(),await,.toString()(sync-only).T.mount(input, node, pos)→update/destroy/value.$global.serializedGlobalsto expose globals to the client (never secrets);cspNonce;renderId/runtimeIdidentifier-safe.
Known agent-error catalog (verified against this repo)
Verified against real components in packages/shadcn/ui/ and notes/bug-marko-dynamic-tag-hydration-crash.md.
-
<if>/<else>must be direct template siblings —<else>is not valid nested inside a wrapper, and nothing (not even whitespace-only text expressions) may sit between</if>and<else>other than other<else-if>/<else>tags.<!-- right --> <if=a>A</if> <else-if=b>B</else-if> <else>C</else> <!-- wrong: wrapping breaks the sibling chain --> <div> <if=a>A</if> </div> <else>C</else> -
Bare top-level
interface/type/constgets parsed as a tag attempt, not TypeScript. Every top-level TS declaration needsexport(orstaticfor runtimeconst/function) in front of it.<!-- wrong: "interface" parses as a custom tag name --> interface Input { name: string } <!-- right --> export interface Input { name: string } <!-- wrong: bare top-level const is a template-body reactive statement, not module scope, and usually not what you meant --> const PAGE_SIZE = 20; <!-- right --> static const PAGE_SIZE = 20; -
!/is ambiguous — Marko overloads!(unescaped-HTML marker,$!{...}) and/(tag-variable marker,<tag/name/>) as adjacent single-character sigils; a hand-written!/sequence risks being parsed as the wrong one. Not independently reproduced against the compiler for this skill (no minimal repro found before publishing — treat as lower-confidence pending verification) — the safe rule regardless: always write the explicit$!{expr}form for unescaped output, and never place a tag-variable directly after an unescaped-output position without a separating space or reordering the attributes.<!-- right: unambiguous --> <div>$!{input.articleHtml}</div> -
Self-recursive tag imports — a tag that imports itself (e.g. a tree node component rendering its own children) must import itself via the relative-file custom-tag path or an explicit PascalCase import of its own compiled file, never by referencing its own bare tag name as if it were already a global custom tag; relative discovery only finds tags under a
tags/directory (see https://markojs.com/docs/reference/custom-tag), so a same-directory recursive component needs an explicit self-import.<!-- tree-node.marko --> import TreeNode from "./tree-node.marko"; <li> ${input.label} <if=input.children?.length> <ul> <for|child| of=input.children> <TreeNode ...child/> </for> </ul> </if> </li> -
SSR→hydration boundary serializability contract (closure-wrap rule). Every value that crosses from server-rendered
<let>/reactive state into the client must be plain, serializable data — never a live object with methods (a Zag service, aconnect()result, any class instance holding closures). This is a REQUIREMENT of Marko's resumability model, not a bug to work around: if resume corrupts, the default read is "an unserializable value is still crossing the boundary somewhere," not "Marko is broken." The fix is to serialize only primitives and re-derive anything stateful inside a template-written closure (the module getter on<zag/api=() => mod .../>, orprops=on<zag>/<zag-machine>) that runs fresh on each side, never a raw value passed throughinput.<!-- wrong: service/api is a live object with methods — throws "Unable to serialize" --> <let/api=switchMachine.connect(service, normalizeProps)> <!-- right: <zag> wires the machine and returns a getter, re-evaluated on each side --> <zag/api=() => switchMachine from=input/> -
Walk-order
<const>hazard — fixed upstream in marko 6.3.46, no workaround needed. When a component was mounted through dynamic-tag-content (<${input.content}/>) nested inside an ancestor's own portal/dynamic-tag mount, that component's OWN walk-time reads of its OWN<const>-derived value — in<if>/<for>branch selectors, attribute spreads, anywhere evaluated during the first mount walk — could fire before the<const>'s lazy/signal-deferred value had ever computed on that fresh scope, throwingTypeError: ... is not a function. This was a genuine defect in@marko/runtime-tags, fixed by marko-js/marko#4062, shipped in@marko/runtime-tags@6.3.46. Plain<zag/api=...>+api()at every call site — the ordinary pattern — is correct; do not reintroduce aconnectFresh-style static-function mirror.<!-- correct, at marko >=6.3.46: plain api() getter, no mirror needed --> <zag/api=() => machine from=input/> <span ...api().getRootProps()> <if=api().open>...</if> </span>Historical note: before the fix landed,
calendar.marko/avatar.marko/radio-group.markoeach carried a module-levelstatic function connectFresh(...)that calledmachine.connect()directly against the raw service, bypassing the<const>entirely, as a workaround. All three were reverted to plainapi()once the workspace'smarkopin moved to 6.3.46 (2026-08-26). Full incident writeup, including the mirror shapes that were tried and still crashed:notes/bug-marko-dynamic-tag-hydration-crash.md. -
Dead-scope closure hazard: content unrendered at SSR time loses its captured variables — pass entry data via tag parameters, not closures. Closure capture itself WORKS: an
<@item>body inside<for|person|>reading${person}renders correctly on the server and through the ordinary hydration walk (always-open control proves it). The hazard is resumability-specific: each content body is serialized as a reference to its OWNER SCOPE (the per-iteration<for>scope holding the loop binding); a body that never rendered during SSR (e.g. behind a closed<if>) gets no serialized owner scope, so when it first fires on a CLIENT-ONLY mount, ANY read through the closure —${person}placeholder or avalue=personattribute expression alike — hits an undefined scope and throwsTypeError: Cannot read properties of undefined (reading 'person')(minified:(reading '2')/'N'). Tag parameters dodge this structurally: the data travels inside the render invocation at fire time (<${entry.content}(entry.value)/>+<@item|name| value=person>reading${name}), not through a serialized scope reference. Menu-like components should therefore always pass entry data to item content via args. Verified as a discriminating pair in the Marko playground (v6.3.45, SSR+hydration). Writeup + playground links:notes/bug-marko-dynamic-tag-hydration-crash.md; minimal repro:scratch/content-closures-repro/. -
JSON.stringify(input.item)shows only the first attr tag by design — the repeated-attr-tag object is "first item + iterable"; stringify serializes own enumerable props only. Count with[...input.item].length, and never branch onArray.isArray(input.item)(it is never an array —Array.isArray(x) ? x : [x]silently discards all but the first tag; spread directly:[...(input.item ?? [])]).
4. Concise-mode vs HTML-mode gotchas
Source: https://markojs.com/docs/reference/concise-syntax
-
Bare root-level text is misparsed as tags in concise mode. Text needs the
--fence (or must live inside an HTML-mode tag's content).<!-- wrong: each word/line is parsed as an attempted tag --> Hello World Welcome to Marko <!-- right --> -- Hello World -- Welcome to Marko -
Attribute lists spanning lines in concise mode use leading commas, by convention at the start of each continuation line (not trailing).
<!-- right --> div id="hello" class="world", style={ border: "1px solid red" } -
A file starts concise and switches to HTML mode at the first HTML-syntax (bracketed) tag — mixing is allowed at the file level (switch once, don't flip back and forth per tag), but a given tag occurrence must be written fully one way or the other.
-
Unenclosed
>in a concise-mode attribute value needs parentheses — a bare>reads as end-of-tag;>=(with surrounding whitespace) and=>are exempted as unambiguous.<!-- wrong: > closes the tag early --> <if=count > 0>...</if> <!-- right --> <if=(count > 0)>...</if>
Samples verified
Every nontrivial .marko sample above that is not an intentionally-broken ("wrong") fragment was dropped into a scratch file under apps/docs/ (so marko's own dependency tree resolves) and compiled with compileFile from node_modules/.bun/marko@6.3.34/node_modules/@marko/compiler/dist/index.js, run via bun (plain node cannot resolve the nested marko/translator self-reference inside bun's .bun/ store layout — bun <script>.mjs is required, matching the compiler resolution the repo's own tooling relies on). All 17 real samples compiled successfully:
OK: 01-class-id-shorthand.marko -> code length 293
OK: 02-dynamic-native-tag.marko -> code length 771
OK: 03-content-forward.marko -> code length 595
OK: 04-attr-tag-collect.marko -> code length 995
OK: 05-tag-params.marko -> code length 575
OK: 05b-tag-params-parent.marko -> code length 865
OK: 06-tag-var-ref.marko -> code length 444
OK: 07-let-script-signal.marko -> code length 492
OK: 08-lifecycle-cleanup.marko -> code length 382
OK: 09-static-function.marko -> code length 651
OK: 10-if-elseif-else.marko -> code length 1459
OK: 11-export-interface-static-const.marko -> code length 635
OK: 12-self-recursive.marko -> code length 2150
OK: 13-if-paren-compare.marko -> code length 1014
OK: 14-root-text-fence.marko -> code length 270
OK: 15-leading-comma-attrs.marko -> code length 310
OK: 16-connect-closure.marko -> code length 1276
OK: 17-connect-fresh-static.marko -> code length 990
The four intentionally-wrong fragments (bare top-level interface, <if>/<else> split by an intervening <div>, unparenthesized <if=input.count > 0>, unenclosed root text without --) were compiled too, to confirm each fails for the reason the corresponding rule claims — not an unrelated syntax mistake. The compiler's own diagnostics independently confirm the rule text:
w1-bare-interface.marko -> "Invalid attribute name." (bare `interface` parsed as a tag)
w2-if-else-wrapped.marko -> "The <else> tag must have a preceding <if=cond> or <else if=cond>."
w3-unparenthesized-if.marko -> "Ambiguous \">\" in attribute... wrap it in parentheses, eg \"=(input.count > 0)\"."
w4-root-text-no-fence.marko -> "Unable to find entry point for custom tag <Hello>... prefix it with -- ... or wrap it in an element"