Primate LogoPrimate

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.

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.

TypeScript JavaScript Go Python Rubyroutes/text.tsroutes/text.jsroutes/text.goroutes/text.pyroutes/text.rb
import route from "primate/route";

export default route({
  get(request) {
    return "Hello from TypeScript!";
  },
});

Use the explicit text handler for more options.

TypeScript JavaScript Go Python Rubyroutes/text.tsroutes/text.jsroutes/text.goroutes/text.pyroutes/text.rb
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 });
  },
});

JSON

Return JSON-serializable objects to serve application/json.

TypeScript JavaScript Go Python Rubyroutes/json.tsroutes/json.tsroutes/json.goroutes/json.pyroutes/json.rb
import route from "primate/route";

export default route({
  get() {
    return [
      { name: "Donald" },
      { name: "John" },
    ];
  },
});

Use the explicit json handler for more options.

TypeScript JavaScriptroutes/json.tsroutes/json.ts
import response from "primate/response";
import http from "@rcompat/http";
import route from "primate/route";

export default route({
  get() {
    return response.json([
      { name: "Donald" },
      { name: "John" },
    ], { status: http.Status.CREATED });
  },
});

Binary

Return Blob, File, ReadableStream or any object exposing { stream(): ReadableStream } to serve application/octet-stream (binary data).

TypeScript JavaScriptroutes/binary.tsroutes/binary.ts
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.

TypeScript JavaScriptroutes/binary.tsroutes/binary.ts
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" },
    });
  },
});

Use rcompat's FileRef to conveniently load a file from disk and stream it out.

TypeScript JavaScriptroutes/binary.tsroutes/binary.ts
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.

TypeScript JavaScriptroutes/redirect.tsroutes/redirect.ts
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.

TypeScript JavaScript Go Python Rubyroutes/redirect.tsroutes/redirect.tsroutes/redirect.goroutes/redirect.pyroutes/redirect.rb
import http from "@rcompat/http";
import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    return response.redirect("https://primate.run", {
      allowExternal: true,
      status: http.Status.SEE_OTHER,
    });
  },
  post(request) {
    return response.redirect(`/login?next=${request.target}`);
  },
});

View

Render and serve components from the views directory as text/html.

TypeScript JavaScript Go Python Rubyroutes/index.tsroutes/index.jsroutes/index.goroutes/index.pyroutes/index.rb
import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    return response.view("Counter.jsx");
  },
});

Props

Populate the component with initial props.

TypeScript JavaScript Go Python Rubyroutes/index.tsroutes/index.jsroutes/index.goroutes/index.pyroutes/index.rb
import response from "primate/response";
import route from "primate/route";

export default route({
  get(request) {
    return response.view("Counter.jsx", { start: 10 });
  },
});

Page

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.

TypeScriptroutes/index.ts
import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    return response.page({ message: "Hello" });
  },
});

The matching component uses the same basename as the route and a frontend extension.

TypeScriptroutes/index.tsx
import type route from "./index";

export default function Page(props: typeof route.get.Page) {
  return <h1>{props.message}</h1>;
}

response.page accepts the same props and view options as response.view.

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.

TypeScript JavaScript Go Python Rubyroutes/not-found.tsroutes/not-found.jsroutes/not-found.goroutes/not-found.pyroutes/not-found.rb
import response from "primate/response";
import route from "primate/route";

export default route({
  get() {
    return response.error({ body: "Not Found" });
  },
});

This 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.

TypeScript JavaScript Go Python Rubyroutes/error.tsroutes/error.jsroutes/error.goroutes/error.pyroutes/error.rb
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,
    });
  },
});

As 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.

An 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");
      },
    });
  },
});

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!");
    });
  },
});

response.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.

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.

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" },
    });
  },
});

ResponseLike reference

type ResponseLike =
  | string
  | Record<string, unknown>
  | Record<string, unknown>[]
  | Blob
  | ReadableStream
  | URL
  | null
  | Response
  ;
Previous
Requests
Next
Views