Marko
Primate runs Marko with server-side rendering, hydration, client navigation, layouts, validation and i18n.
Setup
Install
npm install @primate/marko marko
Configure
import config from "primate/config";
import marko from "@primate/marko";
export default config({
modules: [
marko(),
],
});
Components
Create Marko components in views.
// views/PostIndex.marko
export interface Input {
title: string;
posts: { title: string; excerpt?: string }[];
}
<h1>${input.title}</h1>
<article>
<for|post| of=input.posts>
<div>
<h2>${post.title}</h2>
<if=post.excerpt>
<p>${post.excerpt}</p>
</if>
</div>
</for>
</article>Serve the component from a route:
// routes/posts.ts
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
const posts = [
{ title: "First Post", excerpt: "Introduction to Primate with Marko" },
{ title: "Second Post", excerpt: "Building reactive applications" },
];
return response.view("PostIndex.marko", { title: "Blog", posts });
},
});
Props
Props passed to response.view are available as input in Marko views.
Pass props from a route:
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.view("User.marko", {
user: { name: "John", role: "Developer" },
permissions: ["read", "write"],
});
},
});Access the props in the view:
// views/User.marko
export interface Input {
user: { name: string; role: string };
permissions: string[];
}
<div>
<h2>${input.user.name}</h2>
<p>Role: ${input.user.role}</p>
<ul>
<for|permission| of=input.permissions>
<li>${permission}</li>
</for>
</ul>
</div>
Request
Import the request object from app:marko to access the current request
inside any component. It updates automatically on client-side navigation.
import { request } from "app:marko";
<p>Current path: ${request.url.pathname}</p>The request object exposes a RequestPublic object.
| Property | Type | Description |
|---|---|---|
url |
URL |
current request URL |
query |
Dict<string> |
query string parameters |
headers |
Dict<string> |
request headers |
cookies |
Dict<string> |
request cookies |
Reactivity
Marko's reactive <let> and <const> tags provide fine-grained state
management and computed values.
<let/count=0/>
<const/doubled=() => count * 2/>
<div>
<button onClick() { count-- }>-</button>
<span>${count}</span>
<button onClick() { count++ }>+</button>
<p>Doubled: ${doubled()}</p>
</div>
Validation
Use Primate's <Field> tag from @primate/marko/tags to synchronize state
with backend routes.
// views/Counter.marko
import { Field } from "@primate/marko/tags";
export interface Input {
id: number;
counter: number;
}
<Field/counter value=input.counter method="post" url=`/counter?id=${input.id}`/>
<div style="margin-top: 2rem; text-align: center;">
<h2>Counter Example</h2>
<div>
<button onClick() { counter.update(n => n - 1); } disabled=counter.loading>
-
</button>
<span style="margin: 0 1rem;">${counter.value}</span>
<button onClick() { counter.update(n => n + 1); } disabled=counter.loading>
+
</button>
</div>
<if=counter.error>
<p style="color: red; margin-top: 1rem;">
${counter.error.message}
</p>
</if>
</div>Add corresponding backend validation in the route:
// routes/counter.ts
import Counter from "#store/Counter";
import route from "primate/route";
import response from "primate/response";
import p from "pema";
await Counter.create();
export default route({
async get() {
const counters = await Counter.find({});
const counter = counters.length === 0
? await Counter.insert({ counter: 10 })
: counters[0];
return response.view("Counter.marko", counter);
},
async post(request) {
const id = p.loose.u32.parse(request.query.get("id"));
const counter = p.loose.number.parse(await request.body.json());
await Counter.update(id, { set: { counter } });
return null;
},
});The <Field> tag automatically tracks loading states, captures validation
errors, and posts updates on state changes.
Forms
Use Primate's <Form> tag from @primate/marko/tags to wire forms to backend
routes with automatic field-level validation and error display.
// views/LoginForm.marko
import { Form } from "@primate/marko/tags";
<Form/form initial={ email: "", password: "" } />
<const/email=form.field("email")/>
<const/password=form.field("password")/>
<form
method="post"
action="/login"
id=form.id
onSubmit=form.submit
>
<if=form.errors.length>
<p style="color: red">${form.errors[0]}</p>
</if>
<div>
<input type="email" name=email.name value=email.value placeholder="Email" />
<if=email.error>
<p style="color: red">${email.error}</p>
</if>
</div>
<div>
<input type="password" name=password.name value=password.value placeholder="Password" />
<if=password.error>
<p style="color: red">${password.error}</p>
</if>
</div>
<button type="submit" disabled=form.submitting>
${form.submitting ? "Submitting..." : "Submit"}
</button>
</form>Add the corresponding route:
// routes/login.ts
import route from "primate/route";
import response from "primate/response";
import p from "pema";
const LoginSchema = p({
email: p.string.email(),
password: p.string.min(8),
});
export default route({
get() {
return response.view("LoginForm.marko");
},
async post(request) {
const body = LoginSchema.parse(await request.body.form());
// implement authentication logic
return null;
},
});Validation errors from the server are automatically surfaced per-field via
form.field(name).error. The form.submitting flag disables the submit
button while the request is in flight.
Form API
| Property | Type | Description |
|---|---|---|
form.id |
string |
Unique form ID for the id attr |
form.submit |
(event?) => Promise |
Submit handler for onSubmit |
form.submitting |
boolean |
True while the request is in flight |
form.submitted |
boolean |
True after a successful submission |
form.errors |
string[] |
Form-level errors |
form.field(name) |
Field |
Access a named field |
Field API
| Property | Type | Description |
|---|---|---|
field.name |
string |
Field name for the name attr |
field.value |
T |
Initial field value |
field.error |
string|null |
First validation error or null |
field.errors |
string[] |
All validation errors for field |
Layouts
Create layout components that wrap your pages using input.content.
// views/Layout.marko
export interface Input {
content: Marko.Body;
}
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</header>
<main>
<${input.content}/>
</main>
<footer>© 1996 My App</footer>Next, register the layout via a +layout.ts file:
// routes/+layout.ts
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.view("Layout.marko", { brand: "Primate Marko Demo" });
},
});Pages under this route subtree render inside the layout via input.content.
Internationalization
Create an i18n bridge file that adapts Primate's headless translator to Marko's reactivity model:
// lib/i18n.ts
import app from "#app";
import i18n from "@primate/marko/i18n";
export default i18n(app.i18n);Import and use the bridged translator directly in views:
// views/i18n/Index.marko
import t from "#lib/i18n";
<span>${t.locale.get()}</span>
<span>${t("title")}</span>
<button onClick() { t.locale.set("de-DE") }>
${t("german")}
</button>
<button onClick() { t.locale.set("en-US") }>
${t("english")}
</button>Primate's integration automatically subscribes to locale changes and triggers rerenders when switching languages.
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
| extensions | string[] |
[".marko"] |
Associated file extensions |
| ssr | boolean |
true |
Enable server-side rendering |
| csr | boolean |
true |
Enable client-side rendering |
Example
import marko from "@primate/marko";
import config from "primate/config";
export default config({
modules: [
marko({
// add `.marko.html` to associated file extensions
extensions: [".marko", ".marko.html"],
}),
],
});