DOCUMENTATION Docs Powered by Fuzor Docs

API endpoints

Files in api/ are HTTP endpoints. They run on the server for every request, next to your prerendered pages. Adding the first endpoint makes every build of the app server-backed, for SPA and MPA alike; nothing else in the app changes.

api/
├── status.ts            # /api/status
├── index.ts             # /api
├── users/[id].ts        # /api/users/42
├── files/[...path].ts   # /api/files/a/b/c.txt
└── _db.ts               # private helper, not an endpoint

Handlers

Export a function per HTTP method:

// api/status.ts
export const GET = () => ({ ok: true, now: new Date().toISOString() });

A handler receives a context with request (a Web Request), url, route params and an abort signal. What it returns becomes the response:

Return valueResponse
A ResponseSent as is
undefined204 No Content
Anything else200 JSON with Cache-Control: no-store
A promise or an Effect of the aboveAwaited

Thrown errors and failed Effects become 500 {"error":"Internal server error"} and are passed to onError; their details never reach the client. To send a specific status and message, throw or fail with ApiError:

// api/users/[id].ts
import { ApiError, type ApiContext } from "fuzor/server";
import { findUser } from "./_db.js";

export async function GET({ params }: ApiContext) {
  const user = await findUser(params.id);
  if (!user) throw new ApiError(404, "No such user");
  return { id: user.id, name: user.name };
}

HEAD uses GET without a body, OPTIONS lists the allowed methods, and other methods answer 405 with an Allow header. Catch-all values are joined with /.

Effect handlers

Handlers can return an Effect. The request context is also available as the ServerRequest service:

import * as Effect from "effect/Effect";
import { ServerRequest } from "fuzor/server";

export const GET = () => Effect.gen(function* () {
  const { url } = yield* ServerRequest;
  return { query: url.searchParams.get("q") };
});

Reading bodies and forms

readBody(context, schema) reads JSON, URL-encoded forms or multipart forms and decodes them with an Effect Schema. Form fields are strings, and repeated fields become arrays. Invalid input fails with ApiError(400), an unsupported content type with 415.

// api/guestbook.ts
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { readBody, redirect, type ApiContext } from "fuzor/server";

const Signature = Schema.Struct({ name: Schema.String, message: Schema.String });

export const POST = (context: ApiContext) => Effect.gen(function* () {
  const body = yield* readBody(context, Signature);
  yield* Effect.promise(() => saveSignature(body));
  const wantsJson = context.request.headers.get("accept")?.includes("application/json");
  return wantsJson ? { saved: true } : redirect("/guestbook/", context);
});

A plain HTML form can post straight to an endpoint, in SPA and MPA apps alike:

<form method="post" action="/api/guestbook">
  <input name="name" required>
  <textarea name="message" required></textarea>
  <button>Sign</button>
</form>

Without JavaScript the browser submits it and follows the 303 redirect back to the page. An island can enhance the same form with fetch and accept: application/json. The delivery modes example does exactly this.

The Node server accepts request bodies up to 1 MiB by default (createNodeServer({ maxBodyBytes })); larger bodies are answered with 413.

Security defaults

  • Cross-site mutations are rejected. POST, PUT, PATCH and DELETE requests whose Origin is another site, or that browsers mark Sec-Fetch-Site: cross-site, get 403. This protects cookie-authenticated endpoints from forged form posts. For public APIs called from other sites, export export const config = { crossOrigin: true };.
  • Endpoints stay on the server. Importing anything from api/ in browser code (islands, page scripts) fails the build. Put shared types in a separate folder.
  • Responses are not cached unless your handler returns a Response with its own Cache-Control.
  • No authentication is built in. Read cookies or headers from context.request and check them in each handler, or in a helper in api/_auth.ts.

Cookies and sessions

fuzor/server includes cookie helpers with safe defaults (HttpOnly, SameSite=Lax, Path=/, and Secure except on http://localhost):

import { json, setCookie, type ApiContext } from "fuzor/server";

export const POST = (context: ApiContext) => {
  const response = json({ ok: true });
  return setCookie(response, "theme", "dark", { maxAge: 60 * 60 * 24 * 365 }, context);
};

For sign-in state, sessionCookie stores signed JSON in a cookie. The data is readable by the user but cannot be changed without the secret, so store identifiers, not secrets:

// api/_session.ts
import { sessionCookie } from "fuzor/server";

export const session = sessionCookie<{ userId: string }>({
  name: "session",
  secrets: [process.env.SESSION_SECRET!],   // at least 32 characters; add old secrets after the first to rotate
  maxAgeSeconds: 60 * 60 * 24 * 7
});
// api/login.ts
import { redirect, type ApiContext } from "fuzor/server";
import { session } from "./_session.js";

export const POST = async (context: ApiContext) => {
  const userId = await checkPassword(context);                  // your code
  return session.write(redirect("/account/", context), { userId }, context);
};
// api/me.ts
import { ApiError, type ApiContext } from "fuzor/server";
import { session } from "./_session.js";

export const GET = async (context: ApiContext) => {
  const current = await session.read(context);
  if (!current) throw new ApiError(401, "Sign in first");
  return { userId: current.userId };
};

signValue and verifySignedValue sign individual values the same way. Set cookies on responses you create; Response.redirect() returns immutable headers, so use redirect() from fuzor/server.

Mounting another router

Export fetch to hand every method the file does not export by name to a Fetch API handler. It receives the full request, including /api in the URL, after Fuzor's cross-site check. Use a catch-all file to give a whole path to an Effect HTTP router or any router with a fetch method:

// api/v2/[...path].ts
import { HttpRouter, HttpServerResponse } from "effect/http";

const Routes = HttpRouter.use(router => router.add("GET", "/api/v2/health", HttpServerResponse.text("ready")));
const { handler } = HttpRouter.toWebHandler(Routes);

export const fetch = (request: Request) => handler(request);

A Hono app mounts the same way: export const fetch = (request: Request) => app.fetch(request), with its routes starting at /api/v2.

URL rules

Endpoints live below /api inside the deployment base. Trailing slashes are ignored. Static segments win over [param], which wins over [...rest]. Two files that would match the same URLs (for example api/[id].ts and api/[slug].ts) fail the build, and pages cannot be placed under pages/api/ while api/ exists. Unmatched /api URLs answer 404 JSON.

Development

fuzor dev serves endpoints with the same rules. Changes to endpoint files apply on the next request. Adding the first endpoint changes the build profile, so restart the dev server. fuzor routes lists all endpoints.

Built with Fuzor Back to top ↑
On this page Back to top ↑