Cached at:
07/20/26, 03:31 PM
# Primate 0.40: Route pages, store enums, async schemas and events
Source: [https://primate.run/blog/primate-040](https://primate.run/blog/primate-040)
Today we're announcing the availability of the Primate 0\.40 preview release\.
If you're new to Primate, we recommend reading the[quickstart](https://primate.run/docs/quickstart)page to get started\.
## Collocated route pages[https://primate.run/blog/primate-040#collocated-route-pages](https://primate.run/blog/primate-040#collocated-route-pages)
Primate 0\.40 supports collocated route pages\. Instead of importing a component from`views`and passing it to`response\.view`, a route can render the frontend file next to it with`response\.page`\.
TypeScriptroutes/post/\[id\]\.ts
```
import response from "primate/response";
import route from "primate/route";
export default route({
get(request) {
return response.page({ id: request.path.get("id") });
},
});
```
The frontend file has the same basename as the route, only a different extension\.
ReactAngularVueSvelteSolidMarkoroutes/post/\[id\]\.tsxroutes/post/\[id\]\.component\.tsroutes/post/\[id\]\.vueroutes/post/\[id\]\.svelteroutes/post/\[id\]\.tsxroutes/post/\[id\]\.marko
```
import type route from "./[id]";
export default function Post(props: typeof route.get.Page) {
return <h1>Post {props.id}</h1>;
}
```
The route handles data loading, validation, redirects and status codes; the page handles rendering\.
### Typed props without declarations[https://primate.run/blog/primate-040#typed-props-without-declarations](https://primate.run/blog/primate-040#typed-props-without-declarations)
The component examples above use`typeof route\.get\.Page`for their props\. That type comes from`response\.page\(\.\.\.\)`, so the route response is the source of truth and there's no separate interface to keep in sync\.
### Layout pages[https://primate.run/blog/primate-040#layout-pages](https://primate.run/blog/primate-040#layout-pages)
Layouts can be collocated too\.
TypeScriptroutes/admin/\+layout\.ts
```
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.page({ section: "Admin" });
},
});
```
As with normal routes, add a component alongside the layout with the same basename\.
ReactAngularVueSvelteSolidMarkoroutes/admin/\+layout\.tsxroutes/admin/\+layout\.component\.tsroutes/admin/\+layout\.vueroutes/admin/\+layout\.svelteroutes/admin/\+layout\.tsxroutes/admin/\+layout\.marko
```
import type route from "./+layout";
export default function Layout(
props: typeof route.get.Page & { children: React.ReactNode },
) {
return <main data-section={props.section}>{props.children}</main>;
}
```
The matching layout page receives those props plus its children\. This keeps route\-specific UI close to the route while preserving the existing`views`directory for shared components and explicitly named views\.
## Store enums[https://primate.run/blog/primate-040#store-enums](https://primate.run/blog/primate-040#store-enums)
In Primate 0\.40,`p\.enum`participates in stores\. No more juggling a`p\.u8`column and a separate hand\-rolled constants object that you hope stays in sync\. One declaration is fully validated, fully typed, and carries the enum names with the schema\.
```
// stores/Account.ts
import db from "@/config/db";
import p from "pema";
import store from "primate/store";
const Status = p.enum({
UNCONFIRMED: 0,
CONFIRMED: 1,
});
export default store({
table: "account",
db,
schema: {
id: store.key.primary(p.u32),
email: p.string,
status: Status,
created_at: p.date.default(() => new Date()),
},
}).extend(_Account => ({
Status,
}));
```
Then anywhere else in your app:
```
import Account from "@/stores/Account";
const account = await Account.insert({
email: "
[email protected]",
status: Account.Status.UNCONFIRMED,
});
if (account.status === Account.Status.CONFIRMED) {
// ...
}
```
This removes a few common sources of drift\.
### Plain storage[https://primate.run/blog/primate-040#plain-storage](https://primate.run/blog/primate-040#plain-storage)
`p\.enum`is backed by`p\.u8`under the hood, so it stores as a plain integer from 0 to 255\. There are no dialect\-specific enum types and no`CHECK`constraints to babysit across SQLite, Postgres and MySQL — it rides on`u8`'s existing column mapping\.
### Membership validation[https://primate.run/blog/primate-040#membership-validation](https://primate.run/blog/primate-040#membership-validation)
`p\.u8`alone happily accepts`7`for a two\-value enum\.`p\.enum`validates membership too: only declared values pass\.
### One declared value[https://primate.run/blog/primate-040#one-declared-value](https://primate.run/blog/primate-040#one-declared-value)
The enum is the field, not a sibling constant you remember to update\.`Account\.Status\.CONFIRMED`is generated straight from the schema, not duplicated by hand\.
### Reverse lookup[https://primate.run/blog/primate-040#reverse-lookup](https://primate.run/blog/primate-040#reverse-lookup)
`Account\.Status\.nameOf\(account\.status\)`gets you`"CONFIRMED"`back when you need the name, not just the number\.
## Derived and async schemas[https://primate.run/blog/primate-040#derived-and-async-schemas](https://primate.run/blog/primate-040#derived-and-async-schemas)
Pema schemas can now derive a parsed value into a different value\. Use`\.derive\(\.\.\.\)`when parsed input should be normalized before the handler sees it\.
```
import p from "pema";
const FullName = p({
first: p.string,
last: p.string,
}).derive(({ first, last }) => `${first} ${last}`);
const name = FullName.parse({ first: "John", last: "Adams" });
// name is "John Adams"
```
This works anywhere a Pema schema is accepted\. For request bodies, the derived type flows into the route handler\.
```
import p from "pema";
import route from "primate/route";
const Body = p({
name: p.string,
}).derive(({ name }) => name.toUpperCase());
export default route({
post: route.with(
{ body: Body, contentType: "application/json" },
async request => {
const name = await request.body.json();
// name is string
return name;
},
),
});
```
Primate 0\.40 also supports`p\.async\(\.\.\.\)`for object schemas that need async post\-processing\. Its`parse\(\.\.\.\)`method always returns a promise, and async derives compose just like sync derives\.
```
import p from "pema";
const User = p.async({
id: p.string,
}).derive(async ({ id }) => {
return await loadUser(id);
});
```
Async schemas are supported in`route\.with\(\.\.\.\)`for bodies and path parameters\. For path schemas,`p\.async\(\.\.\.\)`keeps the object shape visible so Primate can still check that route parameters and schema properties match at build time\.
```
// routes/user/[id].ts
import p from "pema";
import route from "primate/route";
const Path = p.async({
id: p.string,
}).derive(async ({ id }) => ({
id: await resolveUserId(id),
}));
export default route({
get: route.with({ path: Path }, request => {
return request.path.get("id");
}),
});
```
Use regular`\.derive\(\.\.\.\)`for synchronous transformations\. Reach for`p\.async\(\.\.\.\)`when the schema has to await I/O, lookup data, or perform another async normalization step before the handler sees the value\.
## Events and simpler SSE cleanup[https://primate.run/blog/primate-040#events-and-simpler-sse-cleanup](https://primate.run/blog/primate-040#events-and-simpler-sse-cleanup)
`response\.sse`now uses a single setup function instead of separate`open`and`close`callbacks\. Return a cleanup function to stop timers or unsubscribe when the browser disconnects\.
```
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.sse(source => {
const timer = setInterval(() => {
source.send("tick", Date.now());
}, 1000);
return () => clearInterval(timer);
});
},
});
```
Primate 0\.40 also includes`primate/events`, an in\-memory channel helper for keyed subscriptions\.
```
// services/DeploymentEvents.ts
import events from "primate/events";
type DeploymentEvent = {
type: "step";
key: number;
status: "running" | "done" | "error";
};
export default events.channel<number, DeploymentEvent>();
```
Emit with a key, subscribe with the same key, and return the unsubscribe function from`response\.sse`\.
```
return response.sse(source => {
return DeploymentEvents.subscribe(deployment.id, event => {
source.send(event.type, event);
});
});
```
Channels are intentionally local to one server process\. They do not persist events or broadcast across multiple running instances\.
## Minor improvements[https://primate.run/blog/primate-040#minor-improvements](https://primate.run/blog/primate-040#minor-improvements)
### App imports move to`@/`[https://primate.run/blog/primate-040#app-imports-move-to](https://primate.run/blog/primate-040#app-imports-move-to)
New Primate apps now use a single`@/\*`TypeScript path alias for application imports instead of the previous family of`\#`aliases\. This avoids collisions with package\-internal import maps and makes app imports read like regular project\-root imports\.
```
{
"compilerOptions": {
"baseUrl": "${configDir}",
"paths": {
"@/*": ["*"]
}
}
}
```
Existing imports map directly to their project folders:
```
import app from "@/config/app";
import db from "@/config/db";
import View from "@/views/Post";
import route from "@/routes/post";
import User from "@/stores/User";
```
This replaces app\-level imports like`\#app`,`\#db`,`\#lib/\*`,`\#view/\*`,`\#route/\*`, and`\#store/\*`\.
### HTML templates move to`templates`[https://primate.run/blog/primate-040#html-templates-move-to-templates](https://primate.run/blog/primate-040#html-templates-move-to-templates)
Primate's HTML shell files now live in`templates`instead of`pages`\. This frees up the term "page" for collocated route pages while making the existing concept clearer: these files are document templates, not route pages\.
```
templates/app.html
templates/error.html
```
The render option has been renamed in the same spirit:
```
return response.view(View, props, { template: "admin.html" });
return response.error({ template: "error.html" });
```
New apps generated with`npx primate init`use`templates/app\.html`, and the old`pages`directory is no longer part of the app structure\.
### Autoapplying migrations[https://primate.run/blog/primate-040#autoapplying-migrations](https://primate.run/blog/primate-040#autoapplying-migrations)
Primate can now autoapply migrations in production\. To activate this, add`autoapply: true`to your configuration:
```
import config from "primate/config";
import db from "@/config/db";
export default config({
db: {
migrations: {
table: "migration",
db,
autoapply: true, // default is false
},
},
});
```
Migrations are then automatically applied when you run`node build/server\.js`in production\. There's no need to run`npx primate migrate:apply`before running the server\.
This removes the need to rely on`node\_modules`or a package manager in production\. You can copy the contents of`build`to your deployment server and run your app with Node, Deno or Bun\. If you're crossbuilding, consider also setting`\-\-target=node`,`\-\-target=deno`or`\-\-target=bun`; see[Crossbuilding](https://primate.run/blog/primate-039#crossbuilding)in the Primate 0\.39 release notes\.
## Fin[https://primate.run/blog/primate-040#fin](https://primate.run/blog/primate-040#fin)
If you like Primate, consider[joining our Discord server](https://discord.gg/RSg4NNwM4f)or starring us on[GitHub](https://github.com/primate-run/primate)\.