Veap runs inside a Next.js App Router application. Next.js keeps doing what it does: static files, its own app/ routes, React Server Components, Server Actions. Veap adds an application layer on top, in the request path and at boot time.
The big picture#
The following diagram illustrates how an incoming HTTP request flows from the Next.js boundary through the Veap virtual routing engine and down to your plugin components:
Two catch-all routes are the seam between Next.js and Veap:
app/[[...catchAll]]/page.tsxforwards page URLs toVeapRouter.app/api/[...catchAll]/route.tsforwards/api/*URLs to plugin API route handlers.
URLs that a physical Next.js page handles never reach the catch-all. Next.js has priority; Veap handles the rest.
Clean Architecture layering#
The @veap/core codebase follows a strict Clean Architecture layout (documented in framework decision record ADR-006). Dependencies point exclusively inward, ensuring that domain rules remain completely agnostic of HTTP transports, database engines, and UI frameworks:
The dependency rule: everything points inward. Application services depend on domain ports (ICookieStore, IMailer, IPasswordHasher, repositories), and the infrastructure layer binds concrete adapters (Next.js cookies, Nodemailer, bcrypt, ActiveRecord repositories) to those ports at boot. This is why services are unit-testable without Next.js, and why transports and hashers are swappable.
Boot lifecycle#
Application (in lib/veap.ts) is built with a fluent builder and bootstrapped once per server process:
export const app = Application.configure()
.withDatabase()
.withAuth()
// ...
.create();
export const initializeSystem = cache(async () => {
return app.bootstrap();
});The following flowchart shows the sequential stages of the boot pipeline:
bootstrap() executes the following steps in order:
- Skips when running during
next build(NEXT_PHASE=phase-production-build) or whenSKIP_VEAP_INIT=true. This is deliberate; prerendering must not boot providers. - Deduplicates across concurrent requests: a second caller awaits the in-flight bootstrap promise; after success a global flag short-circuits further calls.
- Registers the builder's inputs (
AppMigrations,AppPlugins,AppTemplates) into the container. - Instantiates providers in registration order:
KernelServiceProviderfirst, then the ones added bywith*calls. - Calls
register()on every provider (bind contracts and services into the IoC container; do not resolve anything here). - Calls
boot()on every provider (safe to resolve; wires contexts, runs core and app migrations, initializes plugins, registers CLI commands). - Publishes the
system:startevent.
Bootstrap errors are logged, not thrown (except Next.js redirect signals), so a failed boot does not crash the Next.js server process; subsystems that depend on the failed part will fail with "Context is not bound" style errors, which is your signal to check the boot log.
The kernel and service providers#
KernelServiceProvider always runs first. It binds the framework primitives into the container:
EVENT_BUS(the globaleventBussingleton) and theEventBusclass aliasLOGGER(console logger) andLoggerServiceCONFIG_SERVICE(zod-validated environment) andVEAP_CONFIG(veap.config.tsloader)CACHE_PROVIDER(in-memory cache)COOKIE_STOREandREQUEST_CONTEXT(Next.js adapters for cookies/headers/redirect)
Feature providers register their own bindings. For example AuthServiceProvider binds PASSWORD_HASHER to a bcrypt adapter, TOKEN_GENERATOR to an Oslo adapter, repository ports to ActiveRecord implementations, and the six auth application services; then in boot() it binds the AuthContext object that the auth facades read from.
ServiceProvider is the extension point: register() binds, boot() wires. See Service providers for writing your own.
The context pattern#
Application facades never call app() themselves. Each subsystem has a context object that its provider binds once at boot:
// src/application/auth/context.ts (shape)
interface AuthContext {
user: UserService;
session: SessionService;
rbac: RbacService;
passwordReset: PasswordResetService;
emailVerification: EmailVerificationService;
auth: AuthService;
}getCurrentSession() and friends read authContext(). If the provider has not booted, the context getter throws "[Auth] Context is not bound ...". The same pattern exists for plugins and communication. For application code this is an implementation detail; for framework code it is the rule that keeps container lookups out of request code.
Request lifecycle#
The following sequence diagram details how an incoming page request is resolved, protected, and rendered:
For a page request to /tasks/42:
app/layout.tsxawaitsinitializeSystem(), reads the session, resolves the path prefix and rendersI18nProvider+AppProviderwith plugin extension points aroundchildren.- The optional catch-all awaits
initializeSystem(), thenbuildRouteTree(true)merges every enabled plugin's route tree (with the admin prefix resolved) into oneRouteTree. The merged tree is cached in the cache provider in production and invalidated by plugin toggle events. VeapRoutermatches/tasks/42against the tree, producing aMatchResultwith params, a layout chain and the matched node.- Middlewares are collected from the layout chain (outermost first), plus route-level exports;
EnsuredAuthis prepended if any level declaresauth,rolesorpermissions. - The pipeline runs: each middleware can inspect
VeapMiddlewareContext, redirect, or callnext(). The final callback builds the React tree: page wrapped by layouts from innermost to outermost, each level optionally wrapped in itserrorboundary andloadingSuspense, with parallel slots resolved per layout. - If the URL is outside the private prefix and a template is active, the template layout wraps the content.
- Metadata: the catch-all's
generateMetadatacallstree.generateMetadata(path), which mergesgenerateMetadataexports along the matched path.
For /api/* requests the API catch-all performs the same match and instead invokes the route handler export (GET, POST, ...) with the enriched context, wrapped in the API middleware pipeline (ApiEnsuredAuth returns 401 JSON instead of redirecting).
Where the state lives#
- Process-global singletons (
container,eventBus, logger, contexts) live onglobalThisto survive HMR and dual-package boundaries. They are per-server-process, not per-request. - Request-scoped values (active transaction) live in
AsyncLocalStorage. - Per-request caching uses React
cache(getCurrentSession,buildRouteTree,getActiveTemplate). - Persistent state lives in the database (users, sessions, plugin status, settings) and the filesystem (storage provider).