Responses
Route handlers return a ResponseLike value that Primate converts into a
WHATWG Response. You can return simple values ("implicit" responses) or use
explicit handlers from primate/response when you need to control all
aspects of the response.
The language tabs below show equivalent routes where supported. Go, Python,
Ruby, and Kotlin use their backend SDKs rather than importing primate/response.
For exact TypeScript helper overloads, options, defaults, and exported types, see the
primate/response API reference.
| Return value | Handler | Response | Notes |
|---|---|---|---|
string |
text | 200 text/plain |
Serve plain text |
object |
json | 200 application/json |
Serve JSON |
Blob·File·FileRef |
binary | 200 application/octet-stream |
Stream contents |
URL |
redirect | 302 |
Redirect to URL |
| — | view | 200 text/html |
Serve frontend component |
| — | page | 200 text/html |
Serve collocated route page |
| — | error | 404 text/html |
Show error page |
| — | abort | as given | Terminate processing outside normal return flow |
| — | ws | 101 |
WebSocket upgrade |
| — | sse | 200 text/event-stream |
Server‑sent events |
null |
— | 204 |
new Response(null) |
| Response | — | as given | WHATWG Response |
Text
Return strings to serve text/plain.
import route from "primate/route";
export default route({
get(request) {
return "Hello from TypeScript!";
},
});import route from "primate/route";
export default route({
get(request) {
return "Hello from TypeScript!";
},
});package main
import "primate.run/go/route"
var _ = route.Get(func(_ route.Request) any {
return "Hello from Go!"
})package routes.text
import run.primate.Route
fun main() {
Route.get { "Hello from Kotlin!" }
}from primate import Route
@Route.get
def get(request):
return "Hello from Python!"require 'primate/route'
Route.get do |request|
'Hello from Ruby!'
endUse the explicit text handler for more options.
import http from "@rcompat/http";
import response from "primate/response";
import route from "primate/route";
export default route({
post(request) {
return response.text("Hello TypeScript!", { status: http.Status.CREATED });
},
});import http from "@rcompat/http";
import response from "primate/response";
import route from "primate/route";
export default route({
post(request) {
return response.text("Hello JavaScript!", { status: http.Status.CREATED });
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Post(func(request route.Request) any {
return response.Text("Hello from Go!", map[string]any{"status": 201})
})package routes.text
import run.primate.Response
import run.primate.Route
fun main() {
Route.post { Response.Text("Hello from Kotlin!", mapOf("status" to 201)) }
}from primate import Response, Route
@Route.post
def post(request):
return Response.text("Hello from Python!", {"status": 201})require 'primate/route'
require 'primate/response'
Route.post do |request|
Response.text("Hello from Ruby!", status: 201)
endJSON
Return JSON-serializable objects to serve application/json.
import route from "primate/route";
export default route({
get() {
return [
{ name: "Donald" },
{ name: "John" },
];
},
});import route from "primate/route";
export default route({
get() {
return [
{ name: "Donald" },
{ name: "John" },
];
},
});package main
import (
"primate.run/go/core"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return []core.Dict{{"name": "Donald"}, {"name": "John"}}
})package routes.json
import run.primate.Route
fun main() {
Route.get { listOf(mapOf("name" to "Donald"), mapOf("name" to "John")) }
}from primate import Route
@Route.get
def get(request):
return [{"name": "Donald"}, {"name": "John"}]require 'primate/route'
Route.get do |request|
[{ name: 'Donald' }, { name: 'John' }]
endUse the explicit json handler for more options.
import response from "primate/response";
import http from "@rcompat/http";
import route from "primate/route";
export default route({
get() {
return response.json({ ok: true }, { status: http.Status.CREATED });
},
});import response from "primate/response";
import http from "@rcompat/http";
import route from "primate/route";
export default route({
get() {
return response.json({ ok: true }, { status: http.Status.CREATED });
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.JSON(map[string]any{"ok": true}, map[string]any{"status": 201})
})package routes.json
import run.primate.Response
import run.primate.Route
fun main() {
Route.get { Response.Json(mapOf("ok" to true), mapOf("status" to 201)) }
}from primate import Response, Route
@Route.get
def get(request):
return Response.json({"ok": True}, {"status": 201})require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.json({ ok: true }, { status: 201 })
endBinary
Return Blob, File, ReadableStream or any object exposing { stream(): ReadableStream } to serve application/octet-stream (binary data).
import route from "primate/route";
export default route({
get() {
return new Blob(["data"]);
},
});import route from "primate/route";
export default route({
get() {
return new Blob(["data"]);
},
});Primate attempts to read the source's name and MIME type if available. Use the
explicit binary handler for more options.
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.binary(new Blob(["data"]), {
// set filename manually
headers: { "Content-Disposition": "attachment; filename=data.bin" },
});
},
});import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.binary(new Blob(["data"]), {
// set filename manually
headers: { "Content-Disposition": "attachment; filename=data.bin" },
});
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.Binary([]byte{0, 127, 128, 255})
})package routes.binary
import run.primate.Response
import run.primate.Route
fun main() {
Route.get { Response.Binary(byteArrayOf(0, 127, -128, -1)) }
}from primate import Response, Route
@Route.get
def get(request):
return Response.binary(bytes([0, 127, 128, 255]))require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.binary([0, 127, 128, 255])
endThe foreign SDKs accept byte arrays for explicit binary responses; the JS/TS helper additionally accepts streamable sources.
Use rcompat's FileRef to conveniently load a file from disk and stream it out.
import fs from "@rcompat/fs";
import route from "primate/route";
export default route({
get() {
return fs.ref("/tmp/data.bin");
},
});import fs from "@rcompat/fs";
import route from "primate/route";
export default route({
get() {
return fs.ref("/tmp/data.bin");
},
});Redirect
Return a URL to redirect to another address.
import route from "primate/route";
export default route({
get() {
return new URL("https://example.com/login");
},
});import route from "primate/route";
export default route({
get() {
return new URL("https://example.com/login");
},
});Use the explicit redirect handler to vary the status or for local redirects.
String redirects are restricted to the current origin by default. Set
allowExternal: true explicitly for a trusted external destination; never pass
an unvalidated return URL from a request.
import http from "@rcompat/http";
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.redirect("/login", http.Status.SEE_OTHER);
},
post() {
return response.redirect("/login");
},
});import http from "@rcompat/http";
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.redirect("/login", http.Status.SEE_OTHER);
},
post() {
return response.redirect("/login");
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(_ route.Request) any {
return response.Redirect("/login", 303)
})
var _ = route.Post(func(_ route.Request) any {
return response.Redirect("/login")
})package routes.redirect
import run.primate.Response
import run.primate.Route
fun main() {
Route.get { Response.Redirect("/login", 303) }
}from primate import Response, Route
@Route.get
def get(request):
return Response.redirect("/login", 303)
@Route.post
def post(request):
return Response.redirect("/login")require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.redirect('/login', 303)
end
Route.post do |request|
Response.redirect('/login')
endView
Render and serve components from the views directory as
text/html.
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.view("Counter.jsx");
},
});import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.view("Counter.jsx");
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.View("Counter.jsx", nil)
})package routes.view
import run.primate.Response
import run.primate.Route
fun main() {
Route.get { Response.View("Counter.jsx") }
}from primate import Response, Route
@Route.get
def get(request):
return Response.view("Counter.jsx")require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.view('Counter.jsx')
endProps
Populate the component with initial props.
import response from "primate/response";
import route from "primate/route";
export default route({
get(request) {
return response.view("Counter.jsx", { start: 10 });
},
});import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.view("Counter.jsx", { start: 10 });
},
});package main
import (
"primate.run/go/core"
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.View("Counter.jsx", core.Dict{"start": 10})
})package routes.view
import run.primate.Response
import run.primate.Route
fun main() {
Route.get { Response.View("Counter.jsx", mapOf("start" to 10)) }
}from primate import Response, Route
@Route.get
def get(request):
return Response.view("Counter.jsx", {"start": 10})require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.view('Counter.jsx', start: 10)
endPage
Components are embedded into your app's main HTML template at templates/app.html,
with the component code replacing the %body% placeholder. If the app template
doesn't exist, Primate falls back to its standard one.
<!doctype html>
<html>
<head>
<title>Primate app</title>
<meta charset="utf-8" />
%head%
</head>
<body>%body%</body>
</html>Pass a different template option to use another HTML template.
import response from "primate/response";
import route from "primate/route";
// render into `templates/counter.html`
export default route({
get() {
return response.view("Counter.jsx", { start: 10 },
{ template: "counter.html" });
},
});
Placeholders
You can use placeholders in your HTML templates.
Placeholder names start with a letter or underscore and may contain letters, numbers, underscores, and hyphens. Literal percent signs and text that does not match this form are preserved.
<!doctype html>
<html>
<head>
<title>%title%</title>
<meta charset="utf-8" />
%head%
</head>
<body>%body%</body>
</html>Populate them in your routes.
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.view("Counter.jsx", { start: 10 }, {
placeholders: {
title: "Counter",
},
});
},
});
Partial
Pass a partial: true option to render the component without the enclosing
HTML template.
import response from "primate/response";
import route from "primate/route";
// will render Counter without embedding it into templates/app.html
export default route({
get() {
return response.view("Counter.jsx", { start: 10 }, { partial: true });
},
});This is useful for replacing parts of the page whilst retaining the HTML template.
Page
Render a frontend component collocated with the current route using
response.page. This keeps the route handler and its page component next to each
other.
Go, Python, Ruby, and Kotlin backends expose page responses too. Unlike
view, a page response does not specify a component filename: Core selects
the frontend page associated with the current route. Place a matching
routes/index.svelte alongside a root index route (or Index.svelte
alongside Kotlin's Index.kt), and pass props as shown below.
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.page({ message: "Hello" });
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.Page(map[string]any{"name": "Ada"})
})package routes.page
import run.primate.Response
import run.primate.Route
fun main() {
Route.get { Response.Page(mapOf("name" to "Ada")) }
}from primate import Response, Route
@Route.get
def get(request):
return Response.page({"name": "Ada"})require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.page({ name: 'Ada' })
endThe matching component uses the same basename as the route and a frontend extension.
import type route from "./index";
export default function Page(props: typeof route.get.Page) {
return <h1>{props.message}</h1>;
}Page responses accept props and optional view options, as with view. The
foreign SDKs accept JSON-serializable props.
Layouts can use response.page too. A +layout.ts route looks for a collocated
+layout component, such as +layout.tsx, +layout.svelte, or
+layout.vue.
Deferred props
Use response.defer for independent page or layout props that should not hold
up a client-side navigation.
import response from "primate/response";
import route from "primate/route";
export default route({
async get() {
const task = await loadTask();
return response.page({
task,
activity: response.defer(loadActivity(task)),
changes: response.defer(loadChanges(task)),
});
},
});Primate sends the page and immediate props first. Each deferred prop is a promise during client navigation, so use the frontend's normal async boundary. For example, Svelte can await it directly.
{#await activity}
<p>Loading activity…</p>
{:then items}
<Activity items={items} />
{:catch error}
<p>Could not load activity: {error.message}</p>
{/await}In React 19, unwrap the prop inside a Suspense boundary. The value is already resolved during server rendering, so account for both forms.
import { Suspense, use } from "react";
function Activity({ value }) {
const items = value instanceof Promise ? use(value) : value;
return <ActivityList items={items} />;
}
<Suspense fallback={<p>Loading activity…</p>}>
<Activity value={activity} />
</Suspense>The inferred prop type is T | Promise<T>. This works with native promise or
async primitives in Svelte, React, Vue, Solid, Angular, Marko, Voby, and Web
Components. Frontends without client-side rendering resolve the value normally
on the server.
Only direct page and layout props are deferred. Values nested inside another object are not separate rendering boundaries. Initial document requests also resolve deferred props before server rendering; streaming those requests is a separate concern. If a deferred operation fails after navigation starts, its prop promise rejects and the frontend boundary handles the error. Starting a new navigation aborts the previous streamed response.
Error
Serve a 404 Not Found error page as text/html.
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.error({ body: "Not Found" });
},
});import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.error({ body: "Not Found" });
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.Error(map[string]any{
"body": "Not Found",
})
})package routes.error
import run.primate.Response
import run.primate.Route
fun main() {
Route.get { Response.Error("Not Found", 404) }
}from primate import Response, Route
@Route.get
def get(request):
return Response.error({"body": "Not Found"})require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.error(body: 'Not Found')
endThis handler uses the HTML file at templates/error.html or falls back to a
standard one provided by Primate.
<!doctype html>
<html>
<head>
<title>Error page</title>
<meta charset="utf-8" />
%head%
</head>
<body>
<h1>Error page</h1>
<p>
%body%
</p>
</body>
</html>You can pass a custom status to this handler.
import http from "@rcompat/http";
import response from "primate/response";
import route from "primate/route";
export default route({
get(request) {
return response.error({
status: http.Status.INTERNAL_SERVER_ERROR,
});
},
});import http from "@rcompat/http";
import response from "primate/response";
import route from "primate/route";
export default route({
get(request) {
return response.error({
status: http.Status.INTERNAL_SERVER_ERROR,
});
},
});package main
import (
"primate.run/go/core"
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.Error(core.Dict{"status": 500})
})package routes.error
import run.primate.Response
import run.primate.Route
fun main() {
Route.get { Response.Error("Internal Server Error", 500) }
}from primate import Response, Route
@Route.get
def get(request):
return Response.error({"status": 500})require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.error(status: 500)
endAs with view, you can pass a different template option to use another HTML template.
import response from "primate/response";
import route from "primate/route";
// use templates/custom-error.html instead of templates/error.html
export default route({
get() {
return response.error({ template: "custom-error.html" });
},
});
Abort
Use response.abort when code outside a handler's normal return flow must stop
request processing with an intentional HTTP response. This includes path or
query derivations, hooks, nested helpers, and layouts.
import response from "primate/response";
const project = await Project.first({ where: { name } });
if (project === undefined) {
response.abort({
body: "Project not found",
headers: { "Content-Type": "text/plain" },
status: 404,
});
}response.abort throws internally and has return type never; do not write
throw response.abort(...). It accepts either an options object with a required
HTTP status or a native Response.
In Go, Python, Ruby, and Kotlin, return the SDK's abort response from a route
handler instead; these backends do not expose JS/TS control-flow abort().
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
response.abort({ body: "Not Found", status: 404 });
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.Abort(map[string]any{"body": "Not Found", "status": 404})
})package routes.abort
import run.primate.Response
import run.primate.Route
fun main() {
Route.get { Response.Abort(404, "Not Found") }
}from primate import Response, Route
@Route.get
def get(request):
return Response.abort({"body": "Not Found", "status": 404})require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.abort(body: 'Not Found', status: 404)
endAn abort is expected control flow. It bypasses +error, error logging, and
onError, and does not cause the router to try a less-specific route. For an
ordinary response from a route handler, continue to return a value such as
response.error({ status: 404 }). Throw ordinary errors for unexpected failures
that should remain observable.
WebSocket
Upgrade a GET request to ws: and handle open, message, and close
events.
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.ws({
open(socket) {
socket.send("hello");
},
message(socket, message) {
// echo
socket.send(String(message));
},
close(socket) {
console.log("socket closed");
},
});
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.WS(response.WebSocketActions{
Open: func(socket response.WebSocket) { socket.Send("hello") },
Message: func(socket response.WebSocket, message any) {
socket.Send(message)
},
})
})package routes.ws
import run.primate.Response
import run.primate.Route
fun main() {
Route.get {
Response.WS(
Response.WS.Actions(
open = { socket -> socket.send("hello") },
message = { socket, message ->
when (message) {
is Response.WS.Message.Text -> socket.send(message.value)
is Response.WS.Message.Binary -> socket.send(message.value)
}
},
),
)
}
}from primate import Response, Route
@Route.get
def get(request):
def on_open(socket):
socket.send("hello")
def on_message(socket, message):
socket.send(message)
return Response.ws({"open": on_open, "message": on_message})require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.ws(
open: proc { |socket| socket.call('send', 'hello') },
message: proc { |socket, message| socket.call('send', message) },
)
endGo and Kotlin expose typed socket callbacks; Python and Ruby use callback maps. Each connection stays live across the Wasm boundary. The Ruby Wasm VM currently does not initialize under Bun 1.4.4; use Node or Deno for Ruby routes.
Server‑sent events
Push out events to the client as text/event-stream.
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.sse(source => {
// push event to client
source.send("open", "hi!");
});
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.SSE(func(source response.SSESource) func() {
source.SendEvent("open", "hi!")
return nil // Return a cleanup function when subscribing to external events.
})
})package routes.sse
import run.primate.Response
import run.primate.Route
fun main() {
Route.get {
Response.SSE({ source ->
source.send("open", "hi!")
null // Return a cleanup callback when subscribing to external events.
})
}
}from primate import Response, Route
@Route.get
def get(request):
def start(source):
source.send("open", "hi!")
return Response.sse(start)require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.sse do |source|
source.call('send', 'open', 'hi!')
end
endresponse.sse receives a setup function. Return a cleanup function to stop
timers, unsubscribe from event channels, or release any other resources when the
client disconnects.
return response.sse(source => {
const timer = setInterval(() => {
source.send("tick", Date.now());
}, 1000);
return () => clearInterval(timer);
});Named events must use a non-empty, single-line name without control characters.
Call source.send(data) with one string argument to send a default message
event without a custom event name in JS/TS. The foreign SDKs accept JSON-serializable
values and encode them before passing them to Core; Go names events with
SendEvent(name, data).
Call source.close() to end the stream from the server. This closes the
response cleanly and runs the returned cleanup function. Cleanup runs exactly
once whether the server closes the stream or the client disconnects.
For app-level pub/sub, use primate/events and return the
unsubscribe function from the SSE setup.
For a longer JS/TS example see Stream events with SSE;
the SDK-specific callback signatures are shown in the language tabs above.
Response
Return a custom Response.
import http from "@rcompat/http";
import route from "primate/route";
export default route({
get() {
return new Response("Hi!", {
status: http.Status.ACCEPTED,
headers: { "X-Custom": "1" },
});
},
});Native WHATWG Response objects are a JS/TS return option, not an SDK helper
in the four Wasm backends. Use their explicit response types instead. An empty
204 response has its own cross-language helper:
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.null();
},
});package main
import (
"primate.run/go/response"
"primate.run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return response.Null()
})package routes.null_response
import run.primate.Response
import run.primate.Route
fun main() {
Route.get { Response.Null }
}from primate import Response, Route
@Route.get
def get(request):
return Response.null()require 'primate/route'
require 'primate/response'
Route.get do |request|
Response.null
end