Quickstart
The easiest way to get started with Primate is to run it.
$ npx primate
$ deno run -A npm:primate
$ bunx --bun primate
This boots an app from the current directory and serves any route files
under routes
. Let's create one:
import route from "primate/route";
// handle GET requests at this route
route.get(() => "Hello, world!");
import route from "primate/route";
// handle GET requests at this route
route.get(() => "Hello, world!");
Your app now greets you at http://localhost:6161.
Create config
To customize your app, create a config
directory and a config file:
import config from "primate/config";
export default config({
// config comes here
});
import config from "primate/config";
export default config({
// config comes here
});
Add frontend
Install the Primate package for your frontend, plus the frontend itself:
$ npm install @primate/react react react-dom
$ npm install @primate/angular @angular/core @angular/common
$ npm install @primate/vue vue
$ npm install @primate/svelte svelte
$ npm install @primate/solid solid-js babel-preset-solid
@primate/*
packages are officially supported and versioned with Primate.Add the frontend to your config:
import react from "@primate/react";
import config from "primate/config";
export default config({
modules: [react()],
});
import angular from "@primate/angular";
import config from "primate/config";
export default config({
modules: [angular()],
});
import vue from "@primate/vue";
import config from "primate/config";
export default config({
modules: [vue()],
});
import svelte from "@primate/svelte";
import config from "primate/config";
export default config({
modules: [svelte()],
});
import solid from "@primate/solid";
import config from "primate/config";
export default config({
modules: [solid()],
});
Then create a components
directory and a component — here, a simple counter:
import { useState } from "react";
export default function Counter(props) {
const [counter, setCounter] = useState(props.start);
return (
<div style={{ textAlign: "center", marginTop: "2rem" }}>
<h2>Counter Example</h2>
<div>
<button onClick={() => setCounter(counter - 1)}>-</button>
<span style={{ margin: "0 1rem" }}>{counter}</span>
<button onClick={() => setCounter(counter + 1)}>+</button>
</div>
</div>
);
}
import { CommonModule } from "@angular/common";
import type { OnInit } from "@angular/core";
import { Component, Input } from "@angular/core";
@Component({
imports: [CommonModule],
selector: "app-counter",
standalone: true,
template: `
<div style="text-align: center; margin-top: 2rem;">
<h2>Counter Example</h2>
<div>
<button (click)="decrement()">-</button>
<span style="margin: 0 1rem;">{{ count }}</span>
<button (click)="increment()">+</button>
</div>
</div>
`,
})
export default class CounterComponent implements OnInit {
@Input() start = 0;
counter = 0;
ngOnInit() {
this.counter = this.start;
}
increment() {
this.counter++;
}
decrement() {
this.counter--;
}
}
<template>
<div style="text-align: center; margin-top: 2rem;">
<h2>Counter Example</h2>
<div>
<button @click="decrement">-</button>
<span style="margin: 0 1rem;">{{ counter }}</span>
<button @click="increment">+</button>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
const props = defineProps({
start: {
type: Number,
default: 0,
},
});
const counter = ref(props.start);
function increment() {
counter.value++;
}
function decrement() {
counter.value--;
}
</script>
<script>
export let start = 0;
let counter = start;
function increment() {
counter++;
}
function decrement() {
counter--;
}
</script>
<div style="text-align: center; margin-top: 2rem;">
<h2>Counter Example</h2>
<div>
<button on:click={decrement}>-</button>
<span style="margin: 0 1rem;">{counter}</span>
<button on:click={increment}>+</button>
</div>
</div>
import { createSignal } from "solid-js";
export default function Counter(props) {
const [counter, setCounter] = createSignal(props.start);
return (
<div style={{ "text-align": "center", "margin-top": "2rem" }}>
<h2>Counter Example</h2>
<div>
<button onClick={() => setCounter(counter() - 1)}>-</button>
<span style={{ margin: "0 1rem" }}>{counter()}</span>
<button onClick={() => setCounter(counter() + 1)}>+</button>
</div>
</div>
);
}
Serve the component from a route with the view
handler:
import response from "primate/response";
import route from "primate/route";
route.get(() => response.view("Counter.jsx", { start: 10 }));
import response from "primate/response";
import route from "primate/route";
route.get(() => response.view("Counter.component.ts", { start: 10 }));
import response from "primate/response";
import route from "primate/route";
route.get(() => response.view("Counter.vue", { start: 10 }));
import response from "primate/response";
import route from "primate/route";
route.get(() => response.view("Counter.svelte", { start: 10 }));
import response from "primate/response";
import route from "primate/route";
route.get(() => response.view("Counter.jsx", { start: 10 }));
Switch backend
By default, routes run in TypeScript/JavaScript. To add another backend, install its package:
$ npm install @primate/go
$ npm install @primate/python
$ npm install @primate/ruby
$ npm install @primate/grain
Then add it to your config:
import go from "@primate/go";
import config from "primate/config";
export default config({
modules: [go()],
});
import python from "@primate/python";
import config from "primate/config";
export default config({
modules: [python()],
});
import ruby from "@primate/ruby";
import config from "primate/config";
export default config({
modules: [ruby()],
});
import grain from "@primate/grain";
import config from "primate/config";
export default config({
modules: [grain()],
});
And create a route:
import route from "primate/route";
import view from "primate/view";
route.get(() => view("Counter.jsx", { start: 10 }));
import route from "primate/route";
import view from "primate/view";
route.get(() => view("Counter.jsx", { start: 10 }));
package main
import (
"github.com/primate-run/go/core"
"github.com/primate-run/go/response"
"github.com/primate-run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.View("Counter.jsx", core.Dict{"start": 10})
})
from primate import Route, Response
@Route.get
def get(request):
return Response.view("Counter.jsx", {"start": 10})
require 'primate/route'
Route.get do |request|
Primate.view("Counter.jsx", { :start => 10 })
end
module Counter
from "primate/request" include Request
from "primate/response" include Response
from "json" include Json
use Response.{ type Response }
use Request.{ type Request }
provide let get = (request: Request) => Response.view(
"Counter.jsx",
props = JsonObject([("start", JsonNumber(10))]),
)
Add database
To persist data across reloads, install a database package plus pema
for
validation:
$ npm install pema @primate/sqlite
$ npm install pema @primate/postgresql
$ npm install pema @primate/mysql
$ npm install pema @primate/mongodb
$ npm install pema @primate/surrealdb
Configure your database:
import sqlite from "@primate/sqlite";
export default sqlite({
database: "/tmp/app.db",
});
import postgresql from "@primate/postgresql";
export default postgresql({
database: "app",
});
import mysql from "@primate/mysql";
export default mysql({
database: "app",
});
import mongodb from "@primate/mongodb";
export default mongodb({
database: "app",
});
import surrealdb from "@primate/surrealdb";
export default surrealdb({
database: "app",
});
Create a store for your counter:
import i8 from "pema/i8";
import primary from "pema/primary";
import store from "primate/store";
export default store({
id: primary,
value: i8.range(-20, 20),
});
import i8 from "pema/i8";
import primary from "pema/primary";
import store from "primate/store";
export default store({
id: primary,
value: i8.range(-20, 20),
});
Update your component to sync with the backend:
import validate from "@primate/react/validate";
interface Props { counter: number; id: string };
export default function Counter(props: Props) {
const counter = validate<number>(props.counter).post(
`/counter?id=${props.id}`,
);
return (
<div style={{ marginTop: "2rem", textAlign: "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>
{counter.error && <p style={{ color: "red" }}>{counter.error.message}</p>}
</div>);
}
import { NgIf } from "@angular/common";
import { Component, Input } from "@angular/core";
import validate from "@primate/angular/validate";
import type Validated from "@primate/angular/Validated";
@Component({
imports: [NgIf],
template: `
<div style="margin-top: 2rem; text-align: center;">
<h2>Counter Example</h2>
<div>
<button (click)="decrement()" [disabled]="loading">-</button>
<span style="margin: 0 1rem;">{{ value }}</span>
<button (click)="increment()" [disabled]="loading">+</button>
</div>
<p *ngIf="error" style="color:red; margin-top: 1rem;">
{{ error?.message }}
</p>
</div>
`,
})
export default class CounterComponent {
@Input() id: string = "";
@Input("counter") initial: number = 0;
counter!: Validated<number>;
get value() {
return this.counter.value();
}
get loading() {
return this.counter.loading();
}
get error() {
return this.counter.error();
}
ngOnInit() {
this.counter = validate<number>(this.initial)
.post(`/counter?id=${this.id}`);
}
increment() {
this.counter.update(n => n + 1);
}
decrement() {
this.counter.update(n => n - 1);
}
}
<script lang="ts" setup>
import { computed } from "vue";
import validate from "@primate/vue/validate";
interface Props { id: string; counter: number };
const props = defineProps<Props>();
const counter = validate<number>(props.value).post(
`/counter?id=${props.id}`,
);
const loading = computed(() => counter.loading.value);
const error = computed(() => counter.error.value?.message);
</script>
<template>
<div style="margin-top: 2rem; text-align: center;">
<h2>Counter Example</h2>
<div>
<button @click="counter.update(n => n - 1)" :disabled="loading">
-
</button>
<span style="margin: 0 1rem;">{{ counter.value }}</span>
<button @click="counter.update(n => n + 1)" :disabled="loading">
+
</button>
</div>
<p v-if="counter.error" style="color: red; margin-top: 1rem;">
{{ error }}
</p>
</div>
</template>
<script lang="ts">
import validate from "@primate/svelte/validate";
export let id, value;
const counter = validate<number>(value).post(
`/counter?id=${id}`,
);
</script>
<div style="margin-top: 2rem; text-align: center;">
<h2>Counter Example</h2>
<div>
<button
on:click={() => counter.update((n) => n - 1)}
disabled={$counter.loading}
>
-
</button>
<span style="margin: 0 1rem;">{$counter.value}</span>
<button
on:click={() => 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>
import validate from "@primate/solid/validate";
interface Props { counter: number; id: string };
export default function Counter(props: Props) {
const counter = validate<number>(props.counter).post(
`/counter?id=${props.id}`,
);
return (
<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>
{counter.error()
&& <p style={{ color: "red" }}>{counter.error()!.message}</p>}
</div>
);
}
And wire it together with a route:
import Counter from "#store/Counter";
import pema from "pema";
import number from "pema/number";
import string from "pema/string";
import response from "primate/response";
import route from "primate/route";
await Counter.schema.create();
route.get(async () => {
const counters = await Counter.find({});
const counter = counters.length === 0
? await Counter.insert({ value: 10 })
: counters[0];
return response.view("Counter.jsx", counter);
});
route.post(async request => {
// validate that an id was provided
// request.query.get() will throw if id is missing
const id = string.parse(request.query.get("id"));
// validate body as a number, coercing from string first
const body = request.body.fields(pema({ value: number }).coerce);
// update the value in the database
await Counter.update({ id }, { value: body.value });
// 204 no response
return null;
});
import Counter from "#store/Counter";
import pema from "pema";
import number from "pema/number";
import string from "pema/string";
import response from "primate/response";
import route from "primate/route";
await Counter.schema.create();
route.get(async () => {
const counters = await Counter.find({});
const counter = counters.length === 0
? await Counter.insert({ value: 10 })
: counters[0];
return response.view("Counter.jsx", counter);
});
route.post(async request => {
// validate that an id was provided
const id = string.parse(request.query.get("id"));
// validate body as a number, coercing from string first
const body = request.body.fields(pema({ value: number }).coerce);
// update the value in the database
await Counter.update({ id }, { value: body.value });
// 204 no response
return null;
});
coerce
to turn web inputs into typed values before validation.Wrap up
With just a few files, you now have an app that can:
- Map requests to routes
- Render views with multiple frontends
- Run code in different backends
- Persist data in a database
- Validate inputs end-to-end
Next steps
At this point, you've built a minimal but complete app. From here, you can scaffold a project, explore real examples, or dive deeper into the docs — and start building production-ready apps.
- Scaffold with init — Run
npx primate init
for a guided setup. - Explore examples — Browse example apps in the docs and repo.
- Dive deeper — Check out guides and API docs.