API routes & secrets
The server half of the framework: routes, env, sessions, middleware.
Pages render HTML; API routes answer requests. Both live in the same project, share the same env() secrets, and can be gated by one middleware. The server primitives are deliberately small: standard Web Request/Response objects, an HMAC-signed cookie, and three middleware result shapes.
API routes
A top-level api/ directory maps to /api/* routes, exactly like pages/ maps to pages. Every file must begin with // @runsOn api. A file exports one handler per HTTP method - the supported-methods list is the export list:
// src/api/checkout.ts - maps to /api/checkout
// @runsOn api
export function POST(req: Request): Promise<Response> {
return (async () => {
const body = await req.json();
const s = session();
const visits = (s ? s.visits : 0) + 1;
return setSession(
{ visits },
new Response(JSON.stringify({ ok: true, visits }), { status: 201 }),
);
})();
}
export function GET(req: Request) {
const s = session();
return new Response(JSON.stringify({ visits: s ? s.visits : null }), {
headers: { 'Content-Type': 'application/json' },
});
} - Handlers receive the standard Web
Requestand return aResponse(or a promise of one). env()is available;data()is not - it is page-render-time fetching (API_DATA_CALL).- API files are not components: no props, no JSX, no signal rules - ordinary TypeScript.
marisjs devand@marisjs/adapter-nodeserve them;@marisjs/adapter-staticrefuses builds that contain them.
Secrets: env()
env("STRIPE_SECRET_KEY") reads a value from a .env file at build time. The values are baked into the compiled server modules as a snapshot - env() never touches process.env at runtime. A missing key yields undefined, so the fallback idiom works: env("PORT") ?? "3000".
- Callable only from
@runsOn server,@runsOn api, andmiddleware.ts. In a client file it is a hard error (CLIENT_ENV_ACCESS). marisjs initwrites a.gitignorethat excludes.envand a.env.examplewith no real values.- Real process environment takes precedence over the
.envfile - CI can set real secrets without editing it. - An
env()result passed into aclient:hydrateprop triggers a best-effort lint warning (ENV_LEAK_TO_CLIENT_PROP) - secrets do not belong in client bundles.
Sessions
session() reads and HMAC-verifies a signed cookie; setSession(data, response) signs the data, attaches it as a Set-Cookie header, and returns the response. Verification runs on every read, and every failure mode - absent, malformed, tampered - fails safe to null.
- The cookie is
HttpOnly,SameSite=Lax, andSecurewhen built withNODE_ENV=production. - The signing secret comes from
env('SESSION_SECRET')and must be at least 16 characters - a module that uses sessions fails the build without one. - Sessions are stateless by design: no server-side store, no per-session revocation. Rotating the secret invalidates everything.
Middleware
One request gate for the whole site: middleware.ts at the project root, exporting middleware and matcher. It runs before every matching request - pages, API routes, and static files alike:
// middleware.ts - project root, sibling to pages/ and api/
export function middleware(req: Request) {
const s = session();
if (!s) return redirect('/login');
return next();
}
export const matcher: string[] = ['/admin/*']; - The function must return exactly one of
next(),redirect(url, status?), orrespond(response)- anything else is a hard error (MIDDLEWARE_RESULT). matcheris an array of path patterns. Matching is case-sensitive, trailing slashes are significant, and*matches any run of characters.session()andenv()are callable here;data()is not.- Middleware must not consume the request body unless it
respond()s - the matched route receives the request fresh. @marisjs/adapter-noderuns it;@marisjs/adapter-staticrefuses any build that declares middleware.
dist/_server/ tree and are never served statically. Client bundles carry no secrets by construction - the validator blocks env() and session() in @runsOn client files outright.