Skip to Content
Documentation

DevPluginsREST API

Generate an endpoint

Add typed VitNode API routes with vitnode generate route, from a GET starter with sample data to a path parameter and a write scaffold, then call one.

vitnode g route creates a typed route file and adds it to its module's routes. The file name (show.route.ts) and the URL (/{id}) are separate: five common names imply a method and path, and any other name needs both. In this guide you add three endpoints to the notes module of site-notes and call one from a page.

Before you begin

  • The notes module from Generate a module, and pnpm dev running at the workspace root.
  • A terminal in plugins/site-notes.

Add a list endpoint

pnpm vitnode g route notes/list

list implies GET /, so no --method or --path is needed. Each route gets its own file in the module's routes/ folder. The new src/api/modules/notes/routes/list.route.ts answers with sample data, marked sample: true, that its schema describes exactly:

plugins/site-notes/src/api/modules/notes/routes/list.route.ts
import { z } from "@hono/zod-openapi";

import { buildRoute } from "@/api/builders";

/**
 * GET /notes - a generated starter that answers with sample data.
 *
 * Replace the 200 schema and the handler with the real response.
 */
export const listNotesRoute = buildRoute({
  route: {
    method: "get",
    path: "/",
    responses: {
      200: {
        content: {
          "application/json": {
            schema: z.object({ sample: z.literal(true), message: z.string() }),
          },
        },
        description: "Sample data from a generated route.",
      },
    },
  },
  handler: c =>
    c.json(
      {
        sample: true as const,
        message:
          "Sample data from list.route.ts. Replace it with the real response.",
      },
      200,
    ),
});

notes.module.ts now imports it from ./routes/list.route and lists it: routes: [listNotesRoute].

Add a route with a path parameter

pnpm vitnode g route notes/show --id-type number

show implies GET /{id}. For another path, pass --path in OpenAPI syntax, quoted so your shell leaves the braces alone: --path '/{id}/archive'. Path parameters are strings by default. --id-type number validates them as positive integers (z.coerce.number().int().positive()), so /notes/abc is refused with 400 before the handler runs.

Add a write scaffold

pnpm vitnode g route notes/create

create implies POST /. A write route validates its input but has no operation yet, so it answers 501 Not Implemented rather than pretending to succeed:

plugins/site-notes/src/api/modules/notes/routes/create.route.ts
handler: () => {
  throw new HTTPException(501, {
    message: "POST /notes is not implemented yet. Implement it in create.route.ts.",
  });
},

To implement it, fill in the body schema (an empty z.object({}) for now), add the success response to responses, and replace the throw with the real insert. The Permissions lesson shows a finished create route.

Call it from a page

After each route, the generator printed a matching fetcher call. Put the one for show in a temporary component, src/pages/check-notes.tsx:

plugins/site-notes/src/pages/check-notes.tsx
import { Button } from "@vitnode/core/components/ui/button";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { SearchIcon } from "lucide-react";
import { useState } from "react";

export const CheckNotes = () => {
  const [result, setResult] = useState<string>();
  const [isPending, setIsPending] = useState(false);

  const onClick = async () => {
    setIsPending(true);
    try {
      const response = await fetcher({
        plugin: "site-notes",
        method: "get",
        module: "notes",
        path: "/{id}",
        args: { params: { id: 7 } },
      });
      setResult(JSON.stringify(await response.json()));
    } finally {
      setIsPending(false);
    }
  };

  return (
    <div className="flex flex-col gap-2">
      <Button isLoading={isPending} onClick={onClick}>
        <SearchIcon aria-hidden="true" />
        Show note 7
      </Button>
      {result ? <output className="text-sm">{result}</output> : null}
    </div>
  );
};

Render <CheckNotes /> in src/pages/home-page.tsx. TypeScript checks the call against the route: id must be a number, and a path the module does not have, such as /missing, is a compile error.

Check the result

Open /site-notes and press Show note 7:

{ "sample": true, "id": 7, "message": "Sample data from show.route.ts. Replace it with the real response." }

Then remove CheckNotes. Two safety checks are worth knowing:

  • A route the plugin already answers is refused. pnpm vitnode g route notes/all --method get --path / stops with āœ– GET /notes already exists., names listNotesRoute in routes/list.route.ts as the route that answers it, and writes nothing. The check covers the whole plugin, so routes in a parent and a child module that end up on the same URL collide too.
  • A name without a default, such as notes/archive, needs --method and --path in CI. An interactive terminal asks for them.

The plugin's own pages see new routes at once. An app sees them after the plugin's declarations rebuild, which pnpm dev does on save.