Introduction

OverviewPhilosophyStructureUpdatesFAQ

Usage

Other

The standard
Apps

API

The app that exists to be called by other machines.

The api app runs on port 3002. Deploy it to api.{yourdomain}.com.

apps/api is a Next.js app like the other two. It builds to a standalone server and ships as a Docker image:

docker build --build-arg APP=api -t my-project/api .

It is not serverless, and nothing about it assumes a particular host. It is a long-running Node process behind whatever reverse proxy the project uses.

Why it is separate

It renders nothing. Everything it serves is for another machine — a payment provider posting a webhook, a scheduler triggering a job, a load balancer probing readiness, eventually a mobile client.

Keeping that separate from the user-facing apps buys three things: a deliberately stricter security policy, since nothing it returns is a document; independent scaling, since webhook volume and page traffic have nothing to do with each other; and a stable public origin that does not move when the app is redesigned.

It does not buy you a way to reach the database from app or web. Those are Next.js apps too — server components, server actions and route handlers all talk to @kreogen/database directly. Reaching for the API from your own front-end adds a network hop and a serialisation boundary for nothing. Use it when the caller is genuinely external.

The middleware

apps/api/proxy.ts applies security headers and CORS to everything except Next's own assets. It was the one app in the repository exposed to the internet by design, and the last to get any of this.

Headers. The same Nosecone middleware as the browser-facing apps, but with its own Content Security Policy written out rather than spread from the shared one:

contentSecurityPolicy: {
  directives: {
    defaultSrc: ["'none'"],
    frameAncestors: ["'none'"],
    formAction: ["'none'"],
    baseUri: ["'none'"],
    sandbox: [],
  },
}

Nothing this app returns is a document, so denying every source outright is both accurate and stricter than anything derived from a policy that has to allow scripts and styles.

CORS. An allowlist, built from NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_WEB_URL. An origin not on it gets no CORS headers at all, which is what makes a wildcard unnecessary:

const allowedOrigins = [
  process.env.NEXT_PUBLIC_APP_URL,
  process.env.NEXT_PUBLIC_WEB_URL,
].filter(Boolean);

Webhook senders are servers. They send no Origin header and CORS does not apply to them, so this list is only about browsers.

OPTIONS is answered in the middleware with a 204. Next would otherwise 405 a preflight, and the browser reports that as an opaque CORS failure rather than as the missing handler it is. The response also carries Vary: Origin, so a shared cache cannot serve one origin's response to another.

Health and readiness

RouteAnswersTouches
/healthIs the process alive?Nothing
/readyCan this instance serve traffic?Postgres, Redis

Point a container liveness probe at /health and a load balancer at /ready. Swapping them means a database outage restarts every container instead of draining traffic from them — see debugging.

Webhooks

Handlers live in apps/api/app/webhooks. The Stripe one at /webhooks/payments is the worked example, and its shape is worth copying.

Verify the signature first, and reject a bad one with 400. A forged or mis-signed payload is permanently bad — the same bytes will never verify — and Stripe retries 5xx responses for days.

Answer 503, not 200, when the integration is unconfigured. 200 tells the provider the event was handled and stops the retries. A deploy that is merely missing its key would then discard live events permanently instead of having them redelivered once it is fixed.

Claim the event id before doing the work. Providers deliver at least once, not exactly once, so the same event arrives more than once in normal operation:

const claimEvent = async (event: Stripe.Event): Promise<boolean> => {
  try {
    await database.webhookEvent.create({
      data: { id: event.id, provider: 'stripe', type: event.type },
    });

    return true;
  } catch {
    return false;
  }
};

WebhookEvent's primary key is the provider's event id, and the unique constraint is what makes the claim atomic under concurrent deliveries. A redelivery fails the insert, returns early, and answers { received: true, duplicate: true }.

If the handler then throws, the claim is released so the redelivery is processed rather than skipped as a duplicate. Without that, a transient failure mid-handler would be recorded as handled forever.

Processed ids are swept after thirty days by the cleanup job below — comfortably beyond any provider's retry window.

The event is deliberately not echoed back in the response. It carries customer identifiers, amounts and metadata, and no webhook response has a consumer that needs any of it.

Locally, the Stripe CLI forwards to the handler. bun dev --filter api starts the listener alongside the dev server when the CLI is installed.

Scheduled work

Cron routes live in apps/api/app/cron and are ordinary HTTP endpoints. Whatever invokes them — a GitLab scheduled pipeline, a systemd timer, a Kubernetes CronJob — reaches them the same way the internet does, so they authenticate with a shared secret rather than an assumption about the caller:

Authorization: Bearer $CRON_SECRET

The comparison is timing-safe, and the routes are closed by default: with CRON_SECRET unset they answer 503 and log, rather than becoming public endpoints anyone can trigger repeatedly. They accept GET and POST, because invokers disagree about which to use and it is not worth a page of caveats.

/cron/cleanup sweeps expired sessions, verification tokens and pending invitations, plus webhook ids past their retention window. Better Auth writes a session row per sign-in and a verification row per email, and deletes neither once they lapse — so those tables grow with traffic rather than with users, and the indexes get slower for everyone.

Deletions use a 24-hour grace period rather than cutting at the instant of expiry, so clock skew between the app and the database cannot remove a session the app still considers valid.

Prefer a scheduled pipeline over in-app scheduling. Runs are logged, retryable and alertable, and node-cron inside the app fires once per replica.

Adding an endpoint

apps/api/app/users/route.ts
import { requireSession } from '@kreogen/auth/session';
import { database } from '@kreogen/database';

export const GET = async () => {
  await requireSession();

  const users = await database.user.findMany();

  return Response.json(users);
};

The middleware sets headers and CORS; it does not authenticate. Every route decides for itself, and a route that reads tenant data should take the scoped client from requireOrganization() rather than filtering by hand.

Rate limit anything expensive or unauthenticated — see rate limiting.

Calling it from another app

Each app has NEXT_PUBLIC_API_URL in its .env.example, pointing at http://localhost:3002 locally:

apps/app/app/actions/users.ts
'use server';

import { env } from '@/env';

export const getUsers = async () => {
  const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/users`);

  return response.json();
};

Because it is a NEXT_PUBLIC_* value, Next inlines it into the client bundle at build time — which would make every image environment-specific. The Docker entrypoint substitutes it at container start instead, so one image serves staging and production. Adding a new public variable means adding it to that list too; see Docker.