defineApiRoute
Declares a server/api endpoint with public schemas, guards, status and handler; the server validates everything and generates the typed api.* client.
Se autoimporta en el código de servidor: server/ y <script server>.
import { defineApiRoute } from "@meekojs/meeko/server";defineApiRoute declares an HTTP endpoint: the default export of server/api/<ruta>.<método>.ts. server/api/users/[id].get.ts handles GET /api/users/:id. The server validates input with schemas (params, query, body), the session, authorization, Origin and CSRF token, runs the handler and validates its result with response.
Public schemas live in shared/ and implement Standard Schema (Valibot, Zod, ArkType…). From them, Meeko generates the api.* client, used by useApi and useApiMutation with the same types, without importing the handler. Since 1.7, form makes the same endpoint also handle a native <form method="post">, without JavaScript.
Usage#
// shared/schemas/user.ts
import * as v from "valibot";
export const userParams = v.object({ id: v.string() });
export const userSchema = v.object({ id: v.string(), name: v.string() });// server/api/users/[id].get.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
import { userParams, userSchema } from "../../../shared/schemas/user";
export default defineApiRoute({
params: userParams,
response: userSchema,
handler: async ({ params }) => {
const user = await findUser(params.id);
if (!user) throw new ApiError("NOT_FOUND", 404);
return { id: user.id, name: user.name };
},
});In the application, await api.users({ id: "7" }).get() returns { id: string; name: string }.
Type#
function defineApiRoute<
P extends Schema | undefined = undefined,
Q extends Schema | undefined = undefined,
B extends Schema | undefined = undefined,
R extends Schema | undefined = undefined,
S = unknown,
L = undefined,
T extends ApiSuccessStatus = ApiSuccessStatus,
>(
definition: ApiRouteDefinition<P, Q, B, R, S, L, T>,
): ApiRouteDefinition<P, Q, B, R, S, L, T>interface ApiRouteDefinition<
P extends Schema | undefined,
Q extends Schema | undefined,
B extends Schema | undefined,
R extends Schema | undefined,
S = unknown,
L = undefined,
T extends ApiSuccessStatus = ApiSuccessStatus,
> {
params?: P;
query?: Q;
body?: B;
response?: R;
status?: T;
auth?: "required" | "optional";
origin?: "required" | "none";
rateLimit?: RateLimitOptions<S>;
contentType?: "json" | "response";
form?: true | ApiFormOptions<ApiFormResult<R, T>, S>;
authorize?: (context: RequestContext<S>) => boolean | Promise<boolean>;
locals?: (context: RequestContext<S>) => L | Promise<L>;
handler: (args: {
params: OptionalOutput<P>;
query: OptionalOutput<Q>;
body: OptionalOutput<B>;
context: RequestContext<S>;
locals: L;
}) => Promise<ApiHandlerResult<R, T>> | ApiHandlerResult<R, T>;
}interface ApiFormOptions<Result = unknown, S = unknown> {
readonly redirect?: (
result: Result,
context: RequestContext<S>,
) => string | Promise<string>;
readonly maxBytes?: number;
readonly files?: boolean;
readonly echo?: readonly string[];
}Options#
| Option | Type | Default | Description |
|---|---|---|---|
params |
Standard Schema | none | Dynamic segments ([id]), as required strings. Required in a dynamic route and forbidden in a static one. |
query |
Standard Schema | none | The query string. Fields accept strings or string lists; convert numbers inside the schema. |
body |
Standard Schema | none | The JSON body (and, with form, form body). 1 MiB limit. |
response |
Standard Schema | required except with status: 204 or contentType: "response" |
Handler result. A result failing validation responds with 500 without details. |
status |
200 | 201 | 202 | 204 |
200 |
Since 1.2. Successful response status. With 204, no body or response. Must be a literal. |
auth |
"required" | "optional" |
"optional" |
With "required", a request without a session responds with 401. |
authorize |
(context) => boolean | Promise<boolean> |
none | With false, 403 FORBIDDEN. |
origin |
"required" | "none" |
Origin required on POST, PUT, PATCH and DELETE (since 1.8) |
Since 1.8, every mutation requires an allowed Origin, with or without a session; "required" makes this explicit. "none" (unsafe) disables it for requests without a session, including native forms, for a webhook (JSON or form-encoded) or public API; with a session, Origin and CSRF still apply. |
rateLimit |
{ limit, window, key? } |
none | Since 1.2. Request limit per window; above it, 429 with Retry-After. Without key, counts by client IP (context.clientAddress; behind Cloudflare Tunnel, MEEKO_TRUST_PROXY=cloudflare). |
locals |
(context) => L | Promise<L> |
none | Since 1.2. A per-request value (the current user) received by the handler as typed locals. Declare before handler. |
contentType |
"json" | "response" |
"json" |
With "response", the handler returns its own Response (a file, CSV, stream). |
form |
true or { redirect?, maxBytes?, files?, echo? } |
no forms | Since 1.7. Accepts application/x-www-form-urlencoded and multipart/form-data, validated with body, and responds with 303 to the page. echo lists fields whose values return to the page after an error; none by default. See below. |
handler |
({ params, query, body, context, locals }) => resultado |
required | The logic. context is RequestContext: session, services, platform, requestId, clientAddress, signal. |
Each request follows this order: Origin (since 1.8, on every mutation except with origin: "none"), session Origin and CSRF, rateLimit, auth, authorize, input validation, locals, handler and response validation. A native form counts rateLimit earlier, after Origin and before reading the body, because the CSRF token travels in it.
Return value#
The same definition, with inferred types. This is what the server executes and what meeko routes, meeko check and meeko dev generate the client contract from. Responses:
| Case | Response |
|---|---|
| Success | The status value, with response JSON (or no body with 204) |
| Invalid input | 400 INVALID_PARAMS, INVALID_QUERY or INVALID_BODY, with issues: [{ path, message }] (at most 20) |
ApiError(code, status) |
That status, with { code, status, requestId } and its issues if a 4xx |
| Any other error | 500 INTERNAL_ERROR, without the message; logged by the server |
Native form (form) |
303 See Other to the page, with the result in a single-use cookie |
Examples#
Create with 201 and the session#
// server/api/orders/index.post.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
import { newOrder, orderSchema } from "../../../shared/schemas/order";
export default defineApiRoute({
auth: "required",
body: newOrder,
response: orderSchema,
status: 201,
rateLimit: { limit: 20, window: "1m" },
locals: async ({ session }) => {
const user = await findUser((session.current?.data as { userId: string }).userId);
if (!user) throw new ApiError("UNAUTHENTICATED", 401);
return { user };
},
handler: ({ body, locals }) => createOrder(locals.user, body),
});Delete with 204#
// server/api/orders/[id].delete.ts
import { defineApiRoute } from "@meekojs/meeko/server";
import { orderParams } from "../../../shared/schemas/order";
export default defineApiRoute({
params: orderParams,
auth: "required",
status: 204,
handler: async ({ params }) => { await deleteOrder(params.id); },
});await api.orders({ id }).delete() resolves to undefined.
An error in a field#
Since 1.5, an ApiError with issues sends the message to a field, displayed alongside it by useForm:
// server/api/signup.post.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
import { signupInput, signupResult } from "../../shared/schemas/signup";
export default defineApiRoute({
origin: "required",
body: signupInput,
response: signupResult,
status: 201,
handler: async ({ body }) => {
if (await emailTaken(body.email)) {
throw new ApiError("EMAIL_TAKEN", 409, { message: `duplicate ${body.email}`, issues: [{ path: "email", message: "That email already has an account." }] });
}
return createUser(body);
},
});The third argument's message is internal and never sent; issues are public.
A form without JavaScript#
Since 1.7, with form, the same endpoint handles native browser submission. Fields arrive as strings and, if the schema rejects them, Meeko validates them once more with typed parsing ("" → undefined, "on"/"true" → true, "false" → false, a decimal number → number), so v.boolean() and v.number() work unchanged. An asynchronous schema runs only once, without that second attempt:
// server/api/feedback.post.ts
import { defineApiRoute } from "@meekojs/meeko/server";
import { feedbackInput, feedbackSaved } from "../../shared/schemas/feedback";
export default defineApiRoute({
body: feedbackInput,
response: feedbackSaved,
// After an error, the comment returns to the page; no other value does.
form: { redirect: (saved) => `/gracias?id=${saved.id}`, echo: ["comment"] },
handler: ({ body }) => saveFeedback(body),
});<!-- app/components/Feedback.meeko -->
<script lang="ts">
const route = useRoute();
const form = useForm({
schema: feedbackInput,
initial: { page: route.path.value, helpful: false, comment: "" },
action: "/api/feedback",
submit: (values) => api.feedback.post({ body: values }),
});
</script>
<template>
<form method="post" action="/api/feedback" on:submit="form.submit" novalidate>
<input type="hidden" name="return" bind:value="route.url.value">
<input type="hidden" name="_csrf" bind:value="form.csrfToken">
<input type="hidden" name="page" bind:value="form.fields.page.value">
<p if:when="form.error.value" role="alert">{form.error.value}</p>
<textarea name="comment" model="form.fields.comment" aria-label="Comentario"></textarea>
<p if:when="form.errors.value.comment">{form.errors.value.comment}</p>
<button name="helpful" value="true" on:click="() => { form.fields.helpful.value = true; }">This was helpful</button>
<button name="helpful" value="false" on:click="() => { form.fields.helpful.value = false; }">This was not helpful</button>
</form>
</template>With JavaScript, form.submit cancels native submission and sends JSON. Without it, the browser submits the form with the clicked button's name and value (helpful=true reaches the schema as true); success responds with 303 to form.redirect (or the return field, Referer or /), and an error returns with 303 to the page, which renders result messages and values from form.echo fields during SSR, stored in the __Host-meeko-form cookie (HttpOnly, 60 seconds, single-use).
A file with contentType: "response"#
// server/api/reports/[id].get.ts
import { defineApiRoute } from "@meekojs/meeko/server";
import { reportParams } from "../../../shared/schemas/report";
export default defineApiRoute({
params: reportParams,
auth: "required",
contentType: "response",
handler: async ({ params }) => new Response(await renderCsv(params.id), {
headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${params.id}.csv"` },
}),
});A signed webhook with origin: "none"#
A webhook arrives from another server without Origin, and since 1.8, a mutation without Origin responds with 403 INVALID_ORIGIN. origin: "none" disables that route check, so the handler must prove request legitimacy another way, such as the provider's signature:
// server/api/webhooks/payments.post.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
export default defineApiRoute({
origin: "none",
contentType: "response",
handler: async ({ context }) => {
const payload = await context.request.text();
if (!(await verifySignature(payload, context.request.headers.get("x-signature")))) throw new ApiError("INVALID_SIGNATURE", 401);
await recordPayment(JSON.parse(payload));
return new Response(null, { status: 204 });
},
});Common mistakes#
403 INVALID_ORIGIN in a webhook, script or test#
Since 1.8, a POST, PUT, PATCH or DELETE without Origin, with Origin: null or another site's origin responds with 403 INVALID_ORIGIN, even without a session. The browser and api.* clients send Origin automatically; Node fetch or curl do not.
await fetch("http://localhost:4173/api/orders", { method: "POST", headers: { "content-type": "application/json" }, body });await fetch("http://localhost:4173/api/orders", { method: "POST", headers: { "content-type": "application/json", origin: "http://localhost:4173" }, body });For a webhook or public API unable to send it, declare origin: "none" on the route and verify a signature or token (see the example above). A client from another known origin belongs in SessionManager({ allowedOrigins }). See Origin in anonymous mutations.
A schema outside shared/, or missing response#
The client is generated from public schemas without executing the handler, so each must come from shared/, which the browser can import. Since 1.8, simple composition using package functions is supported, such as v.array(item) with item imported from shared/, but not a schema local to the handler.
// server/api/items.get.ts
const item = v.object({ id: v.string() });
export default defineApiRoute({ response: v.array(item), handler: () => [] });meeko check stops with server/api/items.get.ts: response debe venir de shared/: item no se importa de shared/ ni de un paquete, …. Without response: la API requiere un schema público response. See Public schemas.
import { itemList } from "../../shared/schemas/items";
export default defineApiRoute({ response: itemList, handler: () => [] });A dynamic route without params#
// server/api/orders/[id].delete.ts
export default defineApiRoute({ status: 204, handler: async () => {} });server/api/orders/[id].delete.ts: una ruta de API dinámica requiere un schema público params.
export default defineApiRoute({ params: orderParams, status: 204, handler: async ({ params }) => { await deleteOrder(params.id); } });Computed status, or 204 with response#
const deleted = 204;
export default defineApiRoute({ params: orderParams, response: orderSchema, status: deleted, handler: async () => {} });status debe ser el literal 200, 201, 202 o 204. With literal 204 and response: status 204 responde sin cuerpo y no admite un schema response.
export default defineApiRoute({ params: orderParams, status: 204, handler: async () => {} });A result not matching response#
export default defineApiRoute({ body: newOrder, response: orderSchema, handler: ({ body }) => ({ id: 1, item: body.item, qty: body.qty }) });meeko check reports TS2322: Type 'number' is not assignable to type 'string'. in the handler. At runtime, a result failing the schema responds with 500 without details.
export default defineApiRoute({ body: newOrder, response: orderSchema, handler: ({ body }) => ({ id: "1", item: body.item, qty: body.qty }) });Call it from a component#
<script lang="ts">
const route = defineApiRoute({ handler: () => ({}) });
</script>meeko check reports MEEK108: defineApiRoute viene de @meekojs/meeko/server, que sólo existe en el servidor: el <script> y la plantilla de un componente también se ejecutan en el navegador. Úsalo en los handlers de server/api. A component calls the endpoint with api.*, useApi or useApiMutation.
<script lang="ts">
const users = useApi("/api/users");
</script>Notes#
- Security. Always validate with schemas: input never reaches the handler without passing them.
auth,authorizeandlocalsare the endpoint guards: route middleware does not protectserver/api, even if only protected pages call it. An error other thanApiErrornever exposes its message. - CSRF and Origin. With a session, each mutation requires an allowed
Originand CSRF token (x-meeko-csrf, sent automatically by the generated client). A native form (form) requiresOrigin(since1.8, except without a session and withorigin: "none") and, with a session, the_csrffield (useForm'sform.csrfToken); both failures respond with403JSON, without303. - SSR. On the server,
api.*dispatches locally through the same pipeline, with the original request's session and services, without network access. - Forms (
1.7). A repeated name or one ending in[]becomes a list;returnand_csrfdo not reach the schema; an unchecked checkbox sends nothing (declare it withv.optional(v.boolean(), false)). Files requireform.files: true(otherwise,400 FILES_NOT_ALLOWED). The cookie only storesform.echovalues (none by default), never files or fields whose name containspassword,passcode,secret,token,cvcorcvv: do not put card numbers, PINs or other sensitive data inecho, because they return in the cookie and HTML. A prerendered page has no CSRF token in its HTML: a signed-in user submitting without JavaScript receives403 INVALID_CSRF, so a form for signed-in users belongs on an"ssr"page. Withoutform, a form receives415 UNSUPPORTED_MEDIA_TYPE. Withformbut nobody, the handler receivesbody: undefinedand form fields are silently discarded: useful for a button-only form (sign out, delete whatparamsidentifies), so this is not an error; if the form has fields, declare their schema inbody. - Performance.
rateLimitcounts in memory per instance unless the session store shares it (Redis or, since1.7, SQL with driverrateLimit). - OpenAPI.
generateOpenApidocuments the JSON contract with its declared status; it does not describe the form body.