Primate Is the Last Great Web Framework

15 July 2026 • by terrablue

I'm the creator of Primate, so take the title with the appropriate grain of salt. By "great web framework", I mean something specific: a tool that takes ownership of the whole stack. Routing, rendering, data, validation, sessions, deployment targets, runtime support. The pieces should be designed to fit together.

I grew up building web apps with PHP frameworks: mostly Yii, some Laravel. What I loved about them was not PHP itself, but the feeling that the framework owned the stack. Databases, templates, routing, validation, patterns like MVC. You could swap pieces inside one coherent system. You could go to the framework's website and see what was officially supported. You knew what was familiar territory, and what was outside the map.

As JavaScript moved to the server, that idea mostly disappeared. The JavaScript ecosystem strongly prefers composition over cohesion. You choose a server framework, then a database client, then validation, then sessions, then a frontend, then a build tool, then glue it all together. Each piece may be excellent on its own, but nobody owns the seams.

A UNIX purist might say that is the right model: each tool should do one thing well. But web applications are not just pipelines of isolated tools. They are full of shared assumptions: request shapes, validation boundaries, session handling, rendering, routing, serialization, deployment targets. In practice, things often fail in subtle ways exactly where those tools meet.

Meta-frameworks improved the situation, but only by straitjacketing the world. Next gives you a coherent stack, but it is a React stack. If you discover Solid and want to try it, you do not simply switch the view layer. You move to a different meta-framework with different conventions, different routing details and different backend assumptions.

The same pattern repeats across the ecosystem: filesystem routes plus one blessed frontend, rebuilt again and again. React gets one meta-framework. Solid gets another. Svelte gets another. Vue gets another. Each one solves mostly the same backend problems, but in slightly different ways you have to relearn every time.

Runtime fragmentation made this worse. Node, Deno, and Bun are all capable JavaScript runtimes, but some frameworks are now effectively runtime-specific. That means your app can be tied not only to a frontend, but also to a runtime. And when a framework says it "supports" Deno or Bun, that often just means the runtime is compatible enough to run Node code. It does not necessarily mean the framework was designed to use each runtime's own APIs and execution model.

There is no reason for this. Frontends are ways to describe browser UI. Backends are ways to handle requests, data, and application logic. Runtimes are execution targets. These things should be allowed to vary independently.

You should be able to write one route with React, another with Svelte, another with Marko, and keep the same backend model.* You should be able to write backend routes in TypeScript, Go, Ruby, or Python, and compose them inside one app. You should be able to run the same app on Node, Deno, or Bun without changing a single line of code or using runtime-specific packages to bridge the gap.

And the framework should still provide official support for validation, database stores, sessions, i18n, routing, and rendering. That is what Primate is built to be.

Primate is a web framework that does not belong to one frontend, one backend language, or one runtime. It gives you one application model, then lets you choose the pieces route by route. Use React where React makes sense. Use Svelte where Svelte feels better. Use TypeScript for most routes, but Go, Ruby, or Python where their ecosystems or runtime characteristics fit better. Run on Node today, Deno tomorrow, Bun later.

Primate is not hype. It is not benchmark theater. The point is not novelty. The point is removing coupling, lock-in, and decisions other people make for you. You can still reach outside the official stack when you need to, but when you stay inside it, the seams are Primate's responsibility.

A web framework should own the stack without owning your choices.

A Small Example

Here is a tiny app that already shows the point: one backend model, multiple frontends, no framework switch.

Create a project:

mkdir primate-demo
cd primate-demo
npm init -y --init-type=module
npm install primate @primate/react @primate/svelte react react-dom svelte

Configure the frontends:

// config/app.ts
import react from "@primate/react";
import svelte from "@primate/svelte";
import config from "primate/config";

export default config({
  modules: [
    react(),
    svelte(),
  ],
});

Create a React route and collocated page:

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

export default route({
  get() {
    return response.page({
      title: "One app",
      links: [
        { href: "/react", label: "React route" },
        { href: "/svelte", label: "Svelte route" },
      ],
    });
  },
});
// routes/index.tsx
import route from "./index";

export default function Index({ title, links }: typeof route.get.Page) {
  return (
    <main>
      <h1>{title}</h1>
      <p>Same app. Same routing model. Different frontends per route.</p>

      <ul>
        {links.map(link => (
          <li>
            <a href={link.href}>{link.label}</a>
          </li>
        ))}
      </ul>
    </main>
  );
}

Add another React route and page:

// routes/react.ts
import response from "primate/response";
import route from "primate/route";

export default route({
  get(request) {
    return response.page({
      from: request.query.try("from"),
    });
  },
});
// routes/react.tsx
import route from "./react";

export default function React({ from }: typeof route.get.Page) {
  return (
    <main>
      {from && <h1>Hello from {from}</h1>}
      <p>This route is rendered with React.</p>
      <a href="/svelte?from=react">Go to the Svelte route</a>
    </main>
  );
}

Add a Svelte route in the same app:

// routes/svelte.ts
import response from "primate/response";
import route from "primate/route";

export default route({
  get(request) {
    return response.page({
      from: request.query.try("from"),
      now: new Date().toISOString(),
    });
  },
});
<!-- routes/svelte.svelte -->
<script lang="ts">
  import route from "./svelte";

  const props: typeof route.get.Page = $props();
</script>

<main>
  {#if props.from}<h1>Hello from {props.from}</h1>{/if}
  <p>This route is rendered with Svelte.</p>
  <p>Server time: <code>{props.now}</code></p>
  <a href="/react?from=svelte">Go to the React route</a>
</main>

Run it:

npx primate

Or, if you prefer Deno, use deno run -A npm:primate; for Bun, use bunx --bun primate.

This is the part that matters: the backend route model did not change. The app did not become a React app or a Svelte app. It stayed a Primate app, and each route chose the frontend that made sense for it.

* There are limits to this, even in Primate. Different frontend frameworks cannot currently share layouts, because a layout is itself rendered by one frontend. This may become possible later with web components.