Navigated to /docs/getting-started/server-routes

Server routes

Expose deliberate HTTP endpoints through the plugin server boundary using standard web requests and responses.

Choose a server route for an HTTP endpoint

Use a page action when a mutation belongs to one application route. Use a plugin server route for a reusable API, webhook, health check, or integration endpoint that should respond before page rendering.

Create the example's server modules

The examples in Pages and layouts, Fetching data, and Mutating data share this small project API. Create the two modules below before registering its plugin. Keep them under src/server; their imports then match the route examples and remain outside browser bundles.

All sample project records and read endpoints are public. The in-memory store resets on restart and is not shared across server processes. Writes require the local demo session. Replace this store and its demo permission check with your database and resource-specific authorization before deployment.

Create src/server/session.ts

TSCreate: src/server/session.ts
tsimport "@tavojs/core/server-only";

export type Session = { accountId: string };
export type Account = { name: string };

export async function readSession(request: Request): Promise<Session | null> {
  // Local example only: this cookie is not an authenticated session.
  // Replace this adapter with your session provider before deployment.
  if (process.env.NODE_ENV === "production") return null;
  const cookie = request.headers.get("cookie") ?? "";
  return /(?:^|;\s*)session=demo(?:;|$)/.test(cookie)
    ? { accountId: "demo" }
    : null;
}

export async function getAccount(
  accountId: string,
  { signal }: { signal: AbortSignal },
): Promise<Account> {
  signal.throwIfAborted();
  if (accountId !== "demo") throw new Error("Account not found");
  return { name: "Ada" };
}

Create src/server/projects.ts

TSCreate: src/server/projects.ts
tsimport "@tavojs/core/server-only";
import { readSession } from "./session";

export type Project = {
  id: string;
  name: string;
  status: "active" | "archived";
};

export type ProjectInput = { name: string };

// Public sample records, shared in this process and reset on restart.
// Use a database and resource-specific permissions in a real application.
const projects = new Map<string, Project>([
  ["1", { id: "1", name: "Website", status: "active" }],
]);

export function parseProjectInput(input: unknown): ProjectInput {
  const name =
    input !== null && typeof input === "object" && "name" in input
      ? input.name
      : undefined;
  if (typeof name !== "string" || !name.trim()) {
    throw new Error("Project name is required");
  }
  if (name.trim().length > 100) {
    throw new Error("Project name must be 100 characters or fewer");
  }
  return { name: name.trim() };
}

export async function canCreateProject(request: Request): Promise<boolean> {
  // Demo policy: a local demo session can create public sample projects.
  return (await readSession(request)) !== null;
}

export async function listProjects(status: string) {
  return [...projects.values()].filter(
    (project) => status === "all" || project.status === status,
  );
}

export async function projectSummary() {
  return {
    active: [...projects.values()].filter(({ status }) => status === "active")
      .length,
  };
}

export async function getProject(id: string) {
  return projects.get(id) ?? null;
}

export async function createProject(input: ProjectInput) {
  const id = String(projects.size + 1);
  const project: Project = { id, name: input.name, status: "active" };
  projects.set(id, project);
  return project;
}

export const saveProject = createProject;

Register methods in a plugin

Declare every endpoint in the plugin manifest, then implement the same endpoint keys in a lazy server phase. Handlers receive a standard Request and return a terminal Response.

Create src/server/project-api.ts

TSCreate: src/server/project-api.ts
tsimport "@tavojs/core/server-only";
import { definePlugin, definePluginPhase } from "@tavojs/core/plugin";

export const projectApi = definePlugin({
  id: "@project/api",
  version: "1.0.0",
  apiVersion: 1,
  manifest: {
    endpoints: [
      {
        id: "summary",
        methods: ["GET"],
        match: { kind: "exact", path: "/api/projects/summary" },
      },
      {
        id: "collection",
        methods: ["GET", "POST"],
        match: { kind: "exact", path: "/api/projects" },
      },
      {
        id: "project",
        methods: ["GET"],
        match: { kind: "subtree", path: "/api/projects" },
      },
    ],
    exposure: [
      {
        target: "server",
        from: "/api/projects",
        to: "/api/projects",
        reason: "Expose the documented project API.",
      },
    ],
  },
  server: () =>
    definePluginPhase({
      endpoints: {
        summary: async () => {
          const { projectSummary } = await import("./projects");
          return Response.json(await projectSummary());
        },
        collection: async ({ request }) => {
          const store = await import("./projects");
          if (request.method === "POST") {
            if (!(await store.canCreateProject(request))) {
              return Response.json(
                { error: "Authentication required" },
                { status: 401 },
              );
            }
            let input;
            try {
              input = store.parseProjectInput(await request.json());
            } catch {
              return Response.json(
                { error: "Send JSON with a project name of 1–100 characters" },
                { status: 400 },
              );
            }
            return Response.json(await store.createProject(input), {
              status: 201,
            });
          }

          const status =
            new URL(request.url).searchParams.get("status") ?? "all";
          if (!["all", "active", "archived"].includes(status)) {
            return Response.json(
              { error: "Invalid status filter" },
              { status: 400 },
            );
          }
          return Response.json(await store.listProjects(status));
        },
        project: async ({ request }) => {
          const { getProject } = await import("./projects");
          const path = new URL(request.url).pathname;
          const match = /^\/api\/projects\/([^/]+)$/.exec(path);
          if (!match) {
            return Response.json(
              { error: "Project not found" },
              { status: 404 },
            );
          }
          let id;
          try {
            id = decodeURIComponent(match[1]);
          } catch {
            return Response.json(
              { error: "Invalid project ID" },
              { status: 400 },
            );
          }
          const project = await getProject(id);
          return project
            ? Response.json(project)
            : Response.json({ error: "Project not found" }, { status: 404 });
        },
      },
    }),
});

Enable the plugin

Defining a plugin does not expose it by itself. Merge the import and projectApi entry into the existing top-level plugins array in tavo.config.ts, preserving tavoUi(), styles, and other configuration; the manifest exposure maps its declared server path onto the application's public URL tree.

Merge into tavo.config.ts

TSMerge: tavo.config.ts
tsimport { defineConfig } from "@tavojs/core/config";
import { projectApi } from "./src/server/project-api";

export default defineConfig({
  pagesDir: "src/pages",
  plugins: [projectApi],
});

Keep route matching explicit

Use exact matchers for individual URLs and subtree matchers for a deliberate route tree. Read query values and dynamic path pieces from request.url, validate them before use, and keep response formats stable for clients.

  • The manifest matches methods as well as paths. An unsupported method does not automatically invoke this handler or generate a 405; register an explicit method handler when the API contract requires that response, including an Allow header.

  • Return deliberate content-type and cache headers.

  • Keep database and secret-bearing clients in server-only modules.

  • Bound request bodies and remote work with deployment-appropriate limits.

Call the API directly

Start npm run dev:ssr, then run these requests in another terminal. Replace the origin if the server prints a different port. No browser page is needed to exercise a plugin endpoint.

Run in Terminal

BASHRun: Terminal
bashcurl -i http://localhost:4174/api/projects
curl -i http://localhost:4174/api/projects/summary
curl -i http://localhost:4174/api/projects/missing
curl -i 'http://localhost:4174/api/projects?status=invalid'

Preserve the request security boundary

Unsafe methods validate their origin by default. Disable that check only for independently authenticated endpoints such as verified webhooks, and authenticate and authorize every protected operation inside the handler.

Next steps