Primate LogoPrimate

Primate 0.42: Virtual route groups, structured forms and production diagnostics

Today we're announcing the availability of the Primate 0.42 preview release.

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

Virtual route groups

Primate 0.42 adds URL-transparent route directories. Wrap a directory name in parentheses to organize routes and scope special files without adding a segment to the public URL.

routes/
  (account)/
    +layout.ts
    projects.ts
    settings.ts
  [organization]/
    (admin)/
      +hook.ts
      members.ts

routes/(account)/projects.ts serves /projects, and routes/[organization]/(admin)/members.ts serves /:organization/members. Groups can be nested or placed below static and dynamic segments.

The directories remain part of the filesystem hierarchy. An (account) layout frames only that group's pages, while an (admin) hook applies only inside its subtree. This makes it possible to share layout, hook and error behavior among selected siblings without inventing a URL prefix.

Because groups disappear from the URL, Primate checks their complete route patterns for collisions at startup. Static, dynamic, optional and rest overlaps fail with every conflicting source path instead of depending on directory traversal order.

Explicit lazy bundle seams

For client-rendered pages, a group is also a lazy bundle seam. Its collocated pages, layouts, CSS and route-related dependencies are emitted behind a group entrypoint in the partition's shared esbuild graph. The application shell and framework runtime can remain common.

Direct responses preload the active group. On the first client transition into another group, Primate prepares its JavaScript and CSS before disposing the current page. A failed load leaves the current URL, DOM and styles intact; later navigation can retry. Once loaded, navigation within that group returns to the usual JSON-only path.

Nested routes belong to their deepest group, while ancestor layouts remain dependencies. Components in the top-level views directory stay in the application bundle because arbitrary route handlers can select them at runtime.

Virtual groups are organization and performance boundaries, not security boundaries. Their assets can be public and can share chunks. Protect data on the server, and use [client partitions] when generated assets need independent output trees.

See Virtual route groups for naming, collision and chunking details.

Structured client forms

client.form can now serialize a browser form into the nested JSON type declared by its route. Pass serialize to turn FormData into objects, arrays, booleans or another application-specific structure.

import preferences from "@/routes/account/preferences";
import client from "@primate/react/client";

const form = client.form(preferences.post, {
  serialize(data) {
    return {
      notifications: {
        enabled: data.has("notifications[enabled]"),
        channels: data.getAll("notifications[channels]").map(String),
      },
    };
  },
});

The serializer receives successful controls, repeated values and the initiating submit button. Its result is checked against the imported route's body input type and then continues through the normal submitting, result, redirect and validation lifecycle.

Nested Pema issues map back to bracketed field names. For example, /notifications/channels/0 maps to notifications[channels][0]. Field paths recursively check object keys and numeric array indices; an explicit Record<string, T> permits dynamic keys. Nested initial values follow the same path.

For URL-encoded requests, request.body.formEntries() preserves repeated names and submission order. The platform request.original.formData() path likewise keeps repeated controls. Use these for checkbox groups and multi-selects while keeping form() for a last-value record.

See Client forms and Request bodies.

Declarative document heads for Marko

Marko applications can now declare document metadata with the same component ownership model used by Primate's React and Solid integrations.

import { Head } from "@primate/marko/tags";

<Head>
  <title>${input.article.title}</title>
  <meta name="description" content=input.article.summary>
  <link rel="canonical" href=input.article.url>
</Head>

During SSR, Primate collects title, meta, link, style, script and base elements into the document head. Hydration adopts that state without duplicates. Client navigation replaces metadata owned by outgoing pages and layouts while leaving application-template elements untouched. If no active Head supplies a title, the template title is restored.

The implementation validates child elements and title ownership on both server and client. Script and style raw text is preserved even when it contains text that resembles a template closing tag.

See Marko Head.

Application-wide browser integrations can subscribe after Primate has updated both the destination URL and DOM.

import client from "primate/client";

const unsubscribe = client.onNavigation(({ source, url }) => {
  analytics.page(url, { source });
});

The source is "navigate", "form", "history" or "refresh". Successful link navigation, programmatic navigation, enhanced forms, Back, Forward and refresh each notify exactly once. Failed, superseded, shallow, hash-only and full-document navigation do not notify.

Completion is fenced throughout asynchronous asset preparation, frontend disposal and mounting. If another operation takes ownership during one of those steps, the stale operation cannot update history or emit a completion signal. Use the returned function to unsubscribe, and continue using component lifecycle hooks for behavior local to a view.

See Navigation completion.

Observable request failures

Modules can register onError to observe unexpected request failures before Primate renders the application's error route.

setup({ onError }) {
  onError(async ({ frames, phase, request, route }) => {
    const incident = await report({ frames, phase, route });
    request.set("incident", incident.id);
  });
}

Observers receive the original cause, request facade, matched route template, processing phase and mapped application frames. They run inside request context, so an observer can attach a safe incident ID for +error without exposing the exception to the client.

Production builds now include a linked private build/server.js.map. Primate uses it to map bundled stack positions back to application-relative source locations before invoking observers. Source content is not embedded, and the map is neither a client asset nor served by Primate.

The error context distinguishes matching, handlers, layouts, rendering, error handlers and handle middleware. Observer failures are isolated, logged and do not replace the original response.

See Module error observation.

Expected response aborts

Use response.abort when deeply nested application code needs to stop request processing with an intentional response.

import response from "primate/response";

function requireUser(request) {
  if (!request.session.exists) {
    response.abort({ status: 401, body: "Sign in required" });
  }
}

An abort can carry status, headers, body or an existing Response. It bypasses error logging, onError and +error, while still unwinding route hooks and layouts. This keeps expected control flow separate from operational failures.

See Response aborts.

Independent database pools

PostgreSQL, MySQL and MongoDB now expose common pool controls for maximum connections, connection timeout and idle timeout. They also support fork() for work that needs its own capacity and lifecycle.

const dedicated = db.fork({ pool: { max: 2 } });

try {
  await runLongTransaction(dedicated);
} finally {
  await dedicated.close();
}

A fork privately inherits connection settings while owning an independent pool. Closing it does not close the parent, and closing the parent does not close its forks. The shared adapter contract also gives database packages a consistent way to verify this isolation.

See Connection pools.

Production and deployment controls

Two new application settings provide more control over production servers and bundles.

http.timeout configures the in-flight HTTP idle timeout on runtimes that expose one. Development keeps it disabled for debugging and live reload. This is an inactivity timeout rather than an overall request deadline, so long-lived responses can remain open by sending heartbeats.

export default config({
  http: { timeout: 255_000 },
});

build.external keeps selected packages out of the production server bundle. Use it for native addons, adjacent WebAssembly or package-owned data files that must remain in node_modules at runtime.

export default config({
  build: { external: ["satori", "@resvg/resvg-js"] },
});

See App configuration.

Upgrade notes

Client groups are now partitions

The independently built client bundle feature introduced in 0.41 is now named client.partitions. Rename the client.groups key; client.routes remains the URL-prefix mapping.

export default config({
  client: {
    partitions: {
      public: "/app/public/",
      account: "/app/account/",
    },
    routes: {
      "/": "public",
      "/account": "account",
    },
  },
});

Partitions remain independently built asset-isolation boundaries. Virtual route groups are traversable chunk seams inside a partition; the two concepts are intentionally different.

Migration autoapply has been removed

db.migrations.autoapply, deprecated in 0.41, is removed. Leaving the property configured produces an error with migration instructions. Apply migrations explicitly in development:

npx primate migrate:apply

For deployments, apply the exact artifact emitted by the build before starting its server:

node build/migrate.js apply
node build/server.js

This keeps schema changes visible to release jobs and prevents multiple server instances from racing startup migrations.

Dependency refresh

The workspace's external dependencies have been refreshed across frontend, database, build and test packages. The @rcompat/io 0.13 migration adopts its explicit process result model so failed compiler and tooling commands continue to stop Primate operations rather than being mistaken for successful output.

Bug fixes

Client navigation and forms

Routing and rendering

Build, migration, session and runtime reliability

Changes also shipped in 0.41 patches

The 0.41 maintenance line received the fixes that were safe to release without waiting for 0.42: native link interception, redirect/error navigation fallback, independent database pools, structured error observation with private source maps, and Node 24 UUID compatibility. Applications already on the latest 0.41 patch have those fixes; the new APIs and breaking configuration changes above remain part of 0.42.

Fin

If you like Primate, consider joining our Discord server or starring us on GitHub.