Migrate from v1
Dotpress 2 is a rewrite. The style of the API stays (defineRoute(), createRouteGroup(), handlers that return their value), but the framework is no longer built on Express, and there is no compatibility layer. This page lists every change that requires action, in the order of a migration.
Migrate the app first (Runtime to Plugins), with your tests. Then migrate the authentication: its changes reach the clients, the database and the sessions of your users, see What your users will notice.
Runtime
- Node.js 22.12 or later. Every package declares
"engines": { "node": ">=22.12" }. - ES modules only. No CommonJS build: CommonJS projects load the packages with
require(), which Node.js 22.12 supports for ES modules. - Zod 4. Zod 3 is no longer supported. Zod 4 changes some error codes, messages and APIs: see the Zod 4 changelog.
Packages
The dotpress package is replaced by @dotpress/core and an adapter:
Express is no longer a dependency of the core. To keep it, install @dotpress/adapter-express, which supports Express 4 and 5. You can also move to Fastify, Hono or AWS Lambda without changing your routes.
Application
createApp() takes an adapter
createApp() returns a Dotpress app, no longer an Express application:
The Express application is app.native(). Call app.close() on SIGTERM, for a graceful shutdown.
Options
Errors of Dotpress routes never reach the Express error handlers: report them with onError. Errors passed to next(error) by native middlewares are converted to the Dotpress error format.
CORS
v1 enabled CORS by default, without an Access-Control-Allow-Origin header. In v2, CORS is disabled until you list the allowed origins:
Preflight responses list the methods of the path. An OPTIONS request that is not a preflight from an allowed origin gets 405 (or 404), unless you define an OPTIONS route. See CORS.
Routes
Path parameters come from the path
The params: ['id'] form is removed: parameters are typed from the path, group prefixes included.
A Zod schema can still validate or transform them: schema: (z) => ({ params: z.object({ id: z.coerce.number() }) }).
Paths and matching
- Only static and
:paramsegments. Wildcards, optional parameters and regular expressions are rejected at startup. Paths and prefixes start with/. - Case-sensitive: Express was not, by default.
- Static segments win, whatever the declaration order:
/users/meover/users/:id. - Conflicts are rejected: two routes with the same method and path make
createApp()throw. - Express settings (
case sensitive routing,strict routing,query parser) do not apply to Dotpress routes.
See Paths.
Routers replace the test helpers
defineRoute() and createRouteGroup() still register on a global router, mounted when createApp() has no routers. createRouter() creates explicit routers: see Routers.
Handlers
ctx.req and ctx.res are no longer Express objects
They are the same on every adapter:
ctx.req:method,path,url, typedparams,query,bodyandfiles,headers(lowercase names),cookies,ip,signal,header(name);ctx.res:setStatus(),setHeader(),appendHeader(),removeHeader(),setCookie(),clearCookie().
The native request is ctx.raw, as an escape hatch. cookie-parser is no longer needed: ctx.req.cookies is always there. See Request context.
Handlers return the response
A handler can no longer write the response itself:
Returning nothing always responds 204. In v1, it did only with a z.never() response schema, and sent no response otherwise. See Responses.
ctx.req.signal is aborted when the client disconnects: pass it to long work, and streams stop with it.
Middlewares
They run before validation
v1 validated the request, then ran the middlewares. v2 runs the middlewares first:
- middlewares: plugins, app, groups, route;
- request validation;
- the handler;
- response validation, then the response filters.
Two consequences:
- an anonymous request with an invalid body now gets the
401of the authentication middleware, not a400; - middlewares see the raw
params,queryandbody. Only handlers see the parsed values.
Their return value is checked
A middleware returns nothing to continue, or an HttpError or a response helper (redirect()…) to stop the request. Any other value is a bug, and gives a 500.
Errors
One format
Every error, 404 and 500 included, has the same body. Clients that read the code key of the v1 framework errors must read error:
See Responses and errors.
Validation errors
The issues are a flat list, each with its source:
Codes and messages come from Zod 4, unchanged.
Request bodies
v1 parsed JSON only, with the defaults of express.json(). Every adapter of v2 parses bodies the same way:
- The limit is 1 MiB (v1: 100 KB):
bodyLimiton the adapter, or on a route. - The query string is parsed like a form:
a[b]=1is no longer expanded into objects. - Webhook signatures:
rawBody: trueon the route givesreq.rawBody, without a customexpress.json({ verify }).
File uploads
Multer is no longer used. Files are validated by a files schema, and received as Web File objects:
- Invalid files give the usual
400 VALIDATION_FAILED, withsource: 'files'issues. - Uploads count in the body limit: raise
bodyLimiton upload routes. - A repeated field gives an array of files:
arrayOf()always gives an array. - Files are kept in memory, as with the
memoryStorageof v1.
See File uploads.
Plugins
Plugins are objects, declared with definePlugin():
Plugin names are unique; missing or circular dependencies make createApp() throw. See Plugin API.
Typing
AppTypes is replaced by module augmentation. The user type is declared on AuthUser, and other keys on RequestContext:
The augmentation of the Express Request (id, files, user) is removed: use ctx.requestId, ctx.req.files and ctx.user. See Typing.
Authentication
The service and the plugin
configurePlugin(settings) is replaced by a service, createAuth(settings), given to the app with authPlugin(auth). The service also works without an app, in scripts and workers.
Settings to change
-
issueris required, and theissclaim is checked: one value per environment and application, such as the URL of the API. -
Keys in PKCS#8 and SPKI. PKCS#1 keys (
BEGIN RSA PRIVATE KEY) makecreateApp()fail. Converting a key does not change it: existing tokens stay valid. -
Only RS256 is accepted.
-
The
secureflag of cookies istrueby default (v1: only whenNODE_ENVisproduction). Browsers accept secure cookies onhttp://localhost. -
In cookie mode, list the origins of your app if it is not served from the API origin: in the
corsoption withcredentials: true, or incookies.trustedOrigins. Cross-site requests from other origins are no longer authenticated by the cookie. See Cookie mode and CSRF.
The data provider
For an SQL table:
Refresh tokens stored by v1 keep working: they start a family at their next rotation. See Data provider.
New defaults
- Throttling: the default store is in memory. With several instances, configure a shared store. Behind a proxy, the server must trust the proxy headers, or every client shares one counter per username.
- Passwords over 72 bytes make
hashPassword()throw, where bcrypt truncated them: check new passwords withisPasswordTooLong()in your schemas. Existing passwords keep working. - Without the
usernameclaim,ctx.auth.usernameisundefined: usectx.user, orGET /auth/mein the frontend. - Client IP addresses of the login events come from
ctx.req.ip: proxy headers count only when the server trusts them (v1 trusted them always, so addresses could be spoofed).
Responses of the routes
- Tokens set as cookies are no longer in the body. With the refresh token in a cookie, the body has the
accessTokenonly; with both in cookies, no token.userId,expiresInandexpiresAtare always there. - The access token is read from the
Authorization: Bearerheader (or its cookie), no longer from anaccessTokenfield of the body. Send the header on/auth/logouttoo./auth/refreshstill reads its body as before. - Errors: an invalid token on
/auth/refreshor/auth/restoregives401 INVALID_REFRESH_TOKEN(v1:500). New:429 TOO_MANY_LOGIN_ATTEMPTS, withRetry-After, and403 CROSS_ORIGIN_REQUEST. - Logout is idempotent: without a valid token, it answers
200and clears the cookies (v1:401). onLoginFailedhas a newerrorType,'too_many_attempts': an exhaustiveswitchneeds a case. Its events reportclientIdinstead ofclient.
Access tokens
Access tokens follow RFC 9068:
Services that verify the tokens themselves must accept the new aud, and the at+jwt type if they check it.
What your users will notice
To keep the sessions of the cookie mode:
Do not change the path of the refresh token cookie without renaming it: the browser would keep both cookies, and send both.
Checklist
- Node.js 22.12 or later, Zod 4.
-
@dotpress/coreand an adapter installed;dotpressremoved. -
createApp({ adapter }); Express middlewares inbeforeRoutesandafterRoutes. -
onErrorinstead ofonException,exposeErrorsinstead ofisDev, and thecorsorigins listed. -
params: [...]removed; Express path syntax replaced. - Handlers return their response: no
res.json(),res.send()orres.redirect(). - Middlewares checked: they see the raw request, and return nothing, an error or a response.
- Clients read the
errorkey of errors, and the new validation format. - Uploads:
filesschemas,bodyLimiton upload routes. - Plugins declared with
definePlugin(). -
AppTypesreplaced byAuthUserandRequestContextaugmentations. - Auth:
createAuth()withissuer, PKCS#8 and SPKI keys, the new data provider methods and columns. - Auth: the new defaults reviewed (lifetimes, cost, throttling, logout, cookies).
- Clients send the access token in the
Authorizationheader, and no longer read tokens set as cookies from the body. - With several instances: a shared store for the login throttling.