Plugins
VextJS plugins extend application capabilities during startup. They can attach custom capabilities to app, register global middleware, replace selected built-in implementations and manage resource lifecycles. Routes, Services, Adapters and build tooling have their own entry points; see Architecture for their responsibilities.
Run the complete plugin example below first; it needs no external service. Later examples explain individual interfaces. Redis, database, monitoring SDKs and business Services must be supplied by your application. Do not copy all snippets into the plugin directory at once.
Basic concepts
Plugins live under src/plugins/ and are scanned by plugin-loader. Each plugin uses definePlugin() with a name, optional dependencies and a setup() initializer.
Recursive scanning supports .ts, .js, .mjs and .cjs. It excludes names beginning with _ or ., test/spec files and .d.ts. Put ordinary helper modules outside the scanned tree or at explicitly excluded paths. Installing an npm package does not automatically register it as a user plugin; export a plugin from this directory.
Complete example and verification
Use these four files in a separate TypeScript practice project from Quick Start. Keep its npm scripts and tsconfig, merge the base config, and place only these two plugins in the plugin directory. The example verifies dependency order, extension, global middleware, ready and close without Redis or a database. If local/provider config overrides the port, check the actual listen address first.
- Run
npm run dev. In another terminal, requesthttp://127.0.0.1:3000/plugin-infotwice withcurl -i(orcurl.exe -iin PowerShell). - With no other requests, both responses should be 200 with
x-demo-plugin: active;data.ready=trueanddata.requestsshould be 1 then 2. A browser may request a favicon, so use the command above for exact counts. - Press Ctrl+C in the server terminal. Check the
Demo plugin close orderlog forevents: ["consumer", "store"]: the later registered consumer closes first. The log level must include info; forced termination does not guarantee close hooks. - Run
npm run build(the Quick Start build script includes--typecheck), thennpm start. Repeat requests and graceful shutdown; a new process starts its count at zero.
A test helper can retain app.app.demoState, call await app.close(), and check the same event order. That verifies application cleanup but does not replace a CLI signal test.
appExtensions provides an explicit declaration to type generation. The app.extend() call in setup actually creates and attaches state; a type declaration alone creates no runtime capability.
Temporarily change the consumer dependency to a missing name: startup should report the missing dependency. Restore it and retry. Duplicate plugin names also fail instead of silently overriding. Test helpers need explicit plugins: true; see Testing API.
Optional Redis integration
This optional integration requires an installed ioredis package and a reachable Redis service. Creating a client does not prove connection success; the plugin owns cleanup on setup failure or cancellation. This is separate from the four-file example above.
Plug-in interface
These types can be imported from vextjs. VextPluginSetupContext provides a read-only signal: AbortSignal. VextPluginContext does not expose route registration; routes belong in defineRoutes().
name — unique identifier
The plugin name appears in logs, errors and dependency declarations. Duplicate user-plugin names fail before any setup runs; later files do not override earlier ones. Built-in MonSQLize initializes in a separate phase and is not a node in the user-plugin dependency graph. Do not try to replace or depend on it with a same-name file or dependencies: ["monsqlize"].
dependencies — dependency declaration
Declare other user plugin names, not file paths or npm package names. plugin-loader topologically sorts them so dependencies run setup() first. Missing or cyclic dependencies fail fast. A dependency whose setup returns early may still lack the expected capability; the consumer must match its enablement conditions.
setup() — initialization function
The core plugin initializer supports async work. Its second argument is { signal: AbortSignal }; pass it to cancellable I/O. Each setup has a hard timeout controlled by config.plugin.setupTimeout (default 30 seconds, requiring the event loop to run). Failure or timeout aborts the signal, managed mutations are rolled back, and the setup facade is revoked. A late continuation cannot call managed methods or write top-level properties; captured nested objects and external I/O still require plugin-owned cleanup.
onReady() / onClose() — life cycle hook
Plug-ins can also declare onReady(app) and onClose(app) directly. plugin-loader will register them into the application life cycle after setup() is completed:
onReady(app): Executed after HTTP starts listening, suitable for warming up cache, checking external dependencies, and printing startup information.onClose(app): Executed during graceful shutdown. All shutdown hooks clean up resources in last-registration-first-execution (LIFO) order.
The setup mutation facade is also revoked after successful setup. Do not call setters or extend through a retained setup parameter in a later task. Registered callbacks can read the app or use captured clients. Rollback covers managed framework state only; it does not undo network writes or close an external resource whose hook was never registered. A timeout cannot forcibly stop arbitrary JavaScript or I/O. Application shutdown has its own overall deadline.
Plug-in capabilities
app.extend() — Mount custom properties
Attach custom properties or methods to app during plugin setup. Names must be valid JavaScript identifiers and cannot replace existing, reserved or inherited properties. Use the relevant setter to replace a validator or logger.
When using:
If you want to automatically generate plugin extension declarations, export appExtensions = defineAppExtensions<{ ... }>() in the plugin file and run:
Currently, lightweight scanners prioritize inline object generics:
The command will also best-effort scan app.extend("...") calls in the setup / onReady / onClose life cycle of definePlugin(), and write the results into .vext/types/app-extensions.generated.d.ts, and then access the TypeScript project through src/types/generated/index.d.ts. Complex types, imported type alias or dynamic expansion are not suitable for relying on automatic scanning. It is recommended to use handwritten declare module:
When extended app.mailer.send() will get IDE auto-completion without the need for as any assertion.
app.use() — Register global middleware
Plugin middleware runs after global base layers such as request metadata, parsing and response wrapping, and before explicitly enabled CSRF and route chains. It does not run if an earlier layer short-circuits or throws. See the global middleware order.
For application-wide browser security headers, prefer config.securityHeaders so errors, 404 responses, testing helpers, and dev soft reload use the same behavior. The plugin form is useful for scoped or migration-only stacks.
app.use() can only be called in setup(). Calling after route registration is complete will throw an error.
app.hooks.on() — Register runtime lifecycle hooks
Plugins can also observe the framework life cycle through app.hooks.on(name, handler). It is suitable for cross-cutting logic such as request auditing, outbound call monitoring, service call tracking, response header patch, OpenAPI document patch, etc.
app.hooks is a framework reserved property and cannot be overridden with app.extend("hooks", ...). The plugin setup() itself will also trigger plugin:beforeSetup/afterSetup/error, but a plugin cannot observe its own beforeSetup, it can only be observed by previously loaded plugins.
app.onClose() — Graceful closing hook
Register a graceful shutdown hook. On SIGTERM or SIGINT, hooks run in reverse registration order (LIFO). Use them to close database connections, flush logs or cancel timers. Here createDatabaseConnection must be implemented by the application and return a client with disconnect(). A separate sqlDatabase config and sql extension avoid replacing built-in app.db.
app.onReady() — Ready hook
Register ready hook. Triggered after all plug-ins are loaded and HTTP starts listening. Suitable for: warming up cache, checking external dependencies, printing startup information.
app.setValidator() — Replacement validation engine
Replace the synchronous parameter validator. The default is schema-dsl. This adapter preserves its result contract. For a Zod implementation, use the DSL translation example in Validation; do not put Zod instances directly in RouteOptions.validate.
Replace the validator before route registration and Service schema compilation. Preserve the valid/data/errors result contract. Replacing the runtime engine does not expand the static route syntax used by build, Doctor, OpenAPI and generated clients.
app.setThrow() — Wrap error throwing
Wrap or replace app.throw(). The wrapper receives the original implementation and must preserve all overloads and its never return semantics, including i18n shorthand, positional arguments and object arguments. This Proxy forwards all arguments without narrowing them to four positions:
app.setRateLimiter() — Replace rate limiting
Prefer the built-in Redis store for ordinary distributed rate limiting; see Rate Limit. This fixed-window in-memory example uses global max/window. A custom check receives only a key; route-level quota overrides are not passed automatically. Enable rateLimit in configuration first.
This Map removes an expired window only when the same key is accessed again. It has no global capacity or expiry sweep and is not shared across processes. Do not use it unchanged as a production store for an unbounded client set; production implementations must handle those resource limits.
app.setRequestIdGenerator() — Custom request ID
Override the request ID generation algorithm. By default crypto.randomUUID() is used.
Plug-in loading process
Startup timing
In the bootstrap startup process, plugins are executed in the following stages:
This means:
- ✅
app.configcan be accessed insetup()(already loaded) - ✅
app.loggercan be accessed insetup()(already initialized) - ✅
app.extend()/app.use()/app.onClose()/app.onReady()can be called insetup() - ❌
app.servicescannot be accessed insetup()(the service has not been loaded yet) - ❌
setup()cannot assume that the route is registered
To perform an operation after all modules have been loaded, use app.onReady().
Topological sorting
plugin-loader performs topological sorting based on dependencies declarations:
Execution order: database → query-cache → session
If there is a circular dependency (A → B → A), the framework will report a Fail Fast error at startup.
Timeout protection
Automatic Plugin Loader calls in dev, production, and testing use config.plugin.setupTimeout, default 30_000 milliseconds. It must be an integer from 1 through 2,147,483,647, and changes require restart. The manual createTestApp({ setupPlugins }) callback does not use this loader; its deadline belongs to the caller.
On timeout, the framework aborts context.signal, rolls back managed setup mutations and revokes controlled writes through that setup parameter. The plugin still owns external resources created before cancellation. Pass the signal to cancellable operations and close partially initialized clients in its own failure/cancellation path. This mechanism cannot interrupt synchronous code blocking the event loop or forcibly stop arbitrary async I/O, and does not automatically cover the separately initialized built-in database plugin.
Practical example
Database plug-in
This only illustrates a pool interface and close hook; createPool is a stub and does not verify real transactions. Built-in MonSQLize uses config.database and app.db. A custom SQL plugin should use its own config and extension names to avoid conflicts.
Sentry error monitoring plug-in
This is an integration location only: SDK calls are commented out, so no event is actually reported. Middleware catch covers only errors propagated from its next(). It does not cover startup, background work or all errors handled by inner layers; see Hooks for runtime observation.
Scheduled task plug-in
This setInterval is a per-process illustration. Runs can overlap, multiple Workers execute duplicates, and clearing a timer does not cancel work already started. Use Jobs for application-started scheduling, overlap skipping and Redis coordination across replicas. Jobs has no queue, persistent execution records or downtime catch-up.
Built-in plug-ins
VextJS has the following built-in plugins:
The built-in plugin uses shouldLoadMonSQLize() to inspect database config and needs no manual registration. It skips absent config. If enabled but a runtime dependency is missing or config is invalid, fix the startup error rather than expecting a silent skip. See Database.
There is no database.enabled off switch. database: { enabled: false } is still a nonempty object and enters initialization, then fails without database.config.
File upload
VextJS has built-in multipart/form-data parsing, based on Node.js 20+ native Request.formData() API, with zero external dependencies. Just turn it on in configuration.
Enable built-in parsing
When enabled, built-in parsing fills req.files (ParsedFile[]) for multipart/form-data requests. Plain text parts are not automatically added to req.body. When disabled, the built-in multipart branch is skipped.
Used in routing
Use multipart.enabled: true for route-level opt-in when global parsing is disabled. When global parsing is enabled, a route can set multipart.enabled: false to skip built-in parsing. The files map also feeds OpenAPI multipart/form-data generation and required-file runtime checks.
The filename prefix makes this route /upload. This snippet requires both avatar and resume multipart fields. MIME is supplied by the client, not content detection. A request rejected by global parsing cannot be restored by a route override. Non-multipart requests do not trigger files.required, so the handler still checks for a file. See Uploads for full config, curl and 413/415 checks.
ParsedFile structure
multipart.maxFileSize only limits the size of a single file; the total request body read limit is controlled by bodyParser.maxBodySize. When using Fastify, if adapter bodyLimit is additionally configured, the actual read boundary will be the smaller of the overall upper limit of adapter bodyLimit and body-parser.
Custom parsing (advanced)
For finer control, a plugin can use a third-party parser such as busboy. The fragment below reads the entire raw buffer and collects files in memory; it is not a streaming disk-write solution. Install busboy and its types first.
Two usage modes are supported:
- Exclusive mode: Keep
multipart.enabledasfalse(default), and the plugin is solely responsible for parsing - Coexistence mode: When
multipart.enabled: trueis used, the global body-parser parses first, and the plug-in detects and exits early throughreq.files !== undefinedto avoid double parsing.
It is recommended to add guard at the beginning of the plug-in in coexistence mode so that it can be used safely in both scenarios:
app.config.multipart.maxFileSize is enforced only by the built-in parser. A custom parser must implement its own limits, truncated checks and error cleanup; the wiring above does not do so. Total body reading still obeys bodyParser and adapter limits. maxFileSize does not expand that total limit.
Plug-ins vs middleware vs services
Selection Guide:
- Need to initialize resources (such as database connections) at startup → Plug-in
- Need to intercept every request (like authentication check) → Middleware
- Need to encapsulate reusable business logic → Service
- Need to add new capabilities to
app→ plugin (app.extend()) - Need to replace framework built-in behavior → plug-in (
app.setValidator(), etc.)
Best Practices
1. Conditional initialization
Decide whether to initialize the plug-in based on the configuration to avoid wasting resources when they are not needed:
2. Always register shutdown hooks
If a plugin opens an external connection (database, queue or Redis), register app.onClose() or the plugin's onClose for normal shutdown without registering the same resource twice. Cleanup remains subject to the app's overall shutdown deadline. Setup failure or timeout rolls back registered hooks, so the plugin must also release resources created during that setup; normal-close hooks alone are insufficient:
3. Explicitly declare dependencies
If a plugin depends on the injection capabilities of other plugins, be sure to declare it in dependencies instead of assuming load order:
4. Use app.onReady() to perform post-processing operations
Operations that need to wait until all modules are loaded (such as warming up the cache) should be placed in app.onReady() instead of setup():
5. Error tolerance
Only optional capabilities that can truly degrade should catch initialization failure and provide a no-op implementation. Required database or auth capabilities should fail startup. The application provides initAnalytics below and must also clean up a client that is only partially created:
Troubleshooting and verification
Next step
- Understand the Preload mechanism to allow the plug-in package to automatically inject pre-launch scripts (such as OpenTelemetry SDK)
- Learn about the declarative DSL syntax of Parameter Validation
- Learn how to use middleware with plug-ins
- View plug-in related configuration items in Configuration
- Explore Testing How to write tests for plugins