Primate 0.39: Crossbuilding, typed path parameters and Marko 6

hansmighty1 pts0 comments

Primate 0.39: Crossbuilding, typed path parameters and Marko 6<br>31 May 2026 by terrablue<br>Today we're announcing the availability of the Primate 0.39 preview release.

If you're new to Primate, we recommend reading the quickstart page to get<br>started.

Crossbuilding

Primate now supports crossbuilding — compiling your application for a different<br>runtime than the one you're building on. Pass --target to specify the output<br>runtime:

primate build --target=node<br>primate build --target=deno<br>primate build --target=bunBy default, Primate targets the runtime you're currently building on. With<br>crossbuilding, you can develop on Bun and deploy to Node, or build on Node and<br>target Deno — without changing your source code.

Path schemas and typed path parameters

Route handlers can now declare a path schema alongside body, giving path<br>parameters the same validation and narrowing treatment as request bodies.

import route from "primate/route";<br>import p from "pema";

export default route({<br>post: route.with(<br>contentType: "application/x-www-form-urlencoded",<br>path: p({ namespace: p.string }),<br>body: p({ name: p.string.min(2).max(64).regex(/^[a-z0-9-]+$/) }),<br>},<br>async request => {<br>const { namespace } = request.path.toJSON();<br>const { name } = await request.body.form();<br>// namespace and name are fully typed<br>return null;<br>},<br>),<br>});If the path parameters fail validation, Primate returns 400 Bad Request<br>before the handler runs — consistent with how body validation works.

request.path is now a typed RequestBag, meaning get(), try(),<br>has(), and toJSON() all return the types declared in the schema rather<br>than plain strings.

Path params in route clients

When a route declares a path schema, TypeScript enforces it at every call<br>site. Calling the method directly requires passing path:

import route from "#route/[namespace]/project/new";

const response = await route.post({<br>path: { namespace },<br>body: new URLSearchParams({ name }),<br>});Omitting path, or passing the wrong shape, is a compile-time error.

Path params in client.form

Pass path parameters as a second argument to client.form:

const form = client.form(route.post, { path: { namespace } });TypeScript enforces the shape of path based on what the route declares.<br>The client interpolates the URL at runtime — no manual string building<br>required.

This works in React, Svelte, Vue, Solid, and Angular.

Typed form results

client.form now exposes the server's response as form.result, typed to<br>match the route handler's return type:

const form = client.form(route.post);

// form.result is typed as { name: string; foo: string } | null<br>{form.submitted && {JSON.stringify(form.result)}}form.result is null until the form is successfully submitted, and null<br>again on a 204 No Content response.

Marko 6

Primate now ships full support for Marko 6, including server-side<br>rendering, hydration, client navigation, layouts, validation and i18n.

Install

npm install @primate/marko marko

Configure

import config from "primate/config";<br>import marko from "@primate/marko";

export default config({<br>modules: [<br>marko(),<br>],<br>});

Components

Marko views live in views and receive props via the input object. TypeScript<br>interfaces are declared inline with export interface Input:

// views/PostIndex.marko<br>export interface Input {<br>title: string;<br>posts: { title: string; excerpt?: string }[];

${input.title}

|post| of=input.posts>

${post.title}<br>post.excerpt><br>${post.excerpt}

Serve the component from a route:

// routes/posts.ts<br>import response from "primate/response";<br>import route from "primate/route";

export default route({<br>get() {<br>const posts = [<br>{ title: "First Post", excerpt: "Introduction to Primate with Marko" },<br>{ title: "Second Post", excerpt: "Building reactive applications" },<br>];

return response.view("PostIndex.marko", { title: "Blog", posts });<br>},<br>});

Request

Import request from app:marko to access the current request. It updates<br>automatically on client-side navigation:

import { request } from "app:marko";

Current path: ${request.url.pathname}

Validation

The tag from @primate/marko/tags synchronizes reactive state with<br>a backend route:

// views/Counter.marko<br>import { Field } from "@primate/marko/tags";

export interface Input {<br>id: number;<br>counter: number;

/counter value=input.counter method="post" url=`/counter?id=${input.id}`/>

onClick() { counter.update(n => n - 1); } disabled=counter.loading>-<br>${counter.value}<br>onClick() { counter.update(n => n + 1); } disabled=counter.loading>+

counter.error><br>style="color: red;">${counter.error.message}

Forms

The tag from @primate/marko/tags wires a form to a backend route<br>with automatic field-level validation and error display:

// views/LoginForm.marko<br>import { Form } from "@primate/marko/tags";

/form initial={ email: "", password: "" } /><br>const/email=form.field("email")/><br>const/password=form.field("password")/>

method="post" action="/login" id=form.id onSubmit=form.submit><br>form.errors.length><br>style="color: red">${form.errors[0]}

type="email"...

form primate marko path route from

Related Articles