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 value | Response |
|---|---|
A Response | Sent as is |
undefined | 204 No Content |
| Anything else | 200 JSON with Cache-Control: no-store |
| A promise or an Effect of the above | Awaited |
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,PATCHandDELETErequests whoseOriginis another site, or that browsers markSec-Fetch-Site: cross-site, get403. This protects cookie-authenticated endpoints from forged form posts. For public APIs called from other sites, exportexport 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
Responsewith its ownCache-Control. - No authentication is built in. Read cookies or headers from
context.requestand check them in each handler, or in a helper inapi/_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.