Repository: fastify/fastify (2025-02-14T02:13:28Z)

Issue:
Move router options to own key
### Prerequisites

- [X] I have written a descriptive issue title
- [X] I have searched existing issues to ensure the issue has not already been raised


### Issue

From #3805, we can see that people want to pass options to `find-my-way` unhindered. We already have a couple options passed along in `caseSensitive` and `ignoreTrailingSlash` (possibly others). We should deprecate these top-level server options and require a `routingOptions` key that is a hash of `find-my-way` options.


Discussion:
We'll need to keep the old options for a release cycle and deprecate them.
Correct.
Then it's not semver-major ;).
Are you saying you think there is enough time in v3 for the deprecation notices? That's not the approach we took with the `.listen` configuration.
> We'll need to keep the old options for a release cycle and deprecate them.

for v3? or both v3 & v4? I think in v4 we can just leave `routingOptions ` along
Simple developer do not want to know this is a router option. They want to configure the whole fastify instance. The separated routingOptions can play if want to configure it in router level: .get|post(routingOptions), but it is overkill.

Should anybody use this? (I think no)
- /ui/* : caseSensitive = false
- /api/*: caseSensitive = true

Is this still desired as a feature?
I think it would be best. We already do the same for the logger. And allowing it for the router will give developers more flexability.



Patch:
diff --git a/build/build-validation.js b/build/build-validation.js
index 0d880a11f65..ae3a881d862 100644
--- a/build/build-validation.js
+++ b/build/build-validation.js
@@ -43,7 +43,14 @@ const defaultInitOptions = {
   http2SessionTimeout: 72000, // 72 seconds
   exposeHeadRoutes: true,
   useSemicolonDelimiter: false,
-  allowErrorHandlerOverride: true // TODO: set to false in v6
+  allowErrorHandlerOverride: true, // TODO: set to false in v6
+  routerOptions: {
+    ignoreTrailingSlash: false,
+    ignoreDuplicateSlashes: false,
+    maxParamLength: 100,
+    allowUnsafeRegex: false,
+    useSemicolonDelimiter: false
+  }
 }
 
 const schema = {
@@ -103,6 +110,17 @@ const schema = {
     http2SessionTimeout: { type: 'integer', default: defaultInitOptions.http2SessionTimeout },
     exposeHeadRoutes: { type: 'boolean', default: defaultInitOptions.exposeHeadRoutes },
     useSemicolonDelimiter: { type: 'boolean', default: defaultInitOptions.useSemicolonDelimiter },
+    routerOptions: {
+      type: 'object',
+      additionalProperties: false,
+      properties: {
+        ignoreTrailingSlash: { type: 'boolean', default: defaultInitOptions.routerOptions.ignoreTrailingSlash },
+        ignoreDuplicateSlashes: { type: 'boolean', default: defaultInitOptions.routerOptions.ignoreDuplicateSlashes },
+        maxParamLength: { type: 'integer', default: defaultInitOptions.routerOptions.maxParamLength },
+        allowUnsafeRegex: { type: 'boolean', default: defaultInitOptions.routerOptions.allowUnsafeRegex },
+        useSemicolonDelimiter: { type: 'boolean', default: defaultInitOptions.routerOptions.useSemicolonDelimiter }
+      }
+    },
     constraints: {
       type: 'object',
       additionalProperties: {
diff --git a/docs/Reference/Server.md b/docs/Reference/Server.md
index 3bd2ebb074e..73bd761fb9f 100644
--- a/docs/Reference/Server.md
+++ b/docs/Reference/Server.md
@@ -17,9 +17,6 @@ describes the properties available in that options object.
   - [`forceCloseConnections`](#forcecloseconnections)
   - [`maxRequestsPerSocket`](#maxrequestspersocket)
   - [`requestTimeout`](#requesttimeout)
-  - [`ignoreTrailingSlash`](#ignoretrailingslash)
-  - [`ignoreDuplicateSlashes`](#ignoreduplicateslashes)
-  - [`maxParamLength`](#maxparamlength)
   - [`bodyLimit`](#bodylimit)
   - [`onProtoPoisoning`](#onprotopoisoning)
   - [`onConstructorPoisoning`](#onconstructorpoisoning)
@@ -27,16 +24,12 @@ describes the properties available in that options object.
   - [`loggerInstance`](#loggerInstance)
   - [`disableRequestLogging`](#disablerequestlogging)
   - [`serverFactory`](#serverfactory)
-  - [`caseSensitive`](#casesensitive)
-  - [`allowUnsafeRegex`](#allowunsaferegex)
   - [`requestIdHeader`](#requestidheader)
   - [`requestIdLogLabel`](#requestidloglabel)
   - [`genReqId`](#genreqid)
   - [`trustProxy`](#trustproxy)
   - [`pluginTimeout`](#plugintimeout)
-  - [`querystringParser`](#querystringparser)
   - [`exposeHeadRoutes`](#exposeheadroutes)
-  - [`constraints`](#constraints)
   - [`return503OnClosing`](#return503onclosing)
   - [`ajv`](#ajv)
   - [`serializerOpts`](#serializeropts)
@@ -44,8 +37,19 @@ describes the properties available in that options object.
   - [`frameworkErrors`](#frameworkerrors)
   - [`clientErrorHandler`](#clienterrorhandler)
   - [`rewriteUrl`](#rewriteurl)
-  - [`useSemicolonDelimiter`](#usesemicolondelimiter)
   - [`allowErrorHandlerOverride`](#allowerrorhandleroverride)
+  - [RouterOptions](#routeroptions)
+    - [`allowUnsafeRegex`](#allowunsaferegex)
+    - [`buildPrettyMeta`](#buildprettymeta)
+    - [`caseSensitive`](#casesensitive)
+    - [`constraints`](#constraints)
+    - [`defaultRoute`](#defaultroute)
+    - [`ignoreDuplicateSlashes`](#ignoreduplicateslashes)
+    - [`ignoreTrailingSlash`](#ignoretrailingslash)
+    - [`maxParamLength`](#maxparamlength)
+    - [`onBadUrl`](#onbadurl)
+    - [`querystringParser`](#querystringparser)
+    - [`useSemicolonDelimiter`](#usesemicolondelimiter)
 - [Instance](#instance)
   - [Server Methods](#server-methods)
     - [server](#server)
@@ -214,74 +218,6 @@ in front.
 > ℹ️ Note:
 >  At the time of writing, only node >= v14.11.0 supports this option
 
-### `ignoreTrailingSlash`
-<a id="factory-ignore-slash"></a>
-
-+ Default: `false`
-
-Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) to handle
-routing. By default, Fastify will take into account the trailing slashes.
-Paths like `/foo` and `/foo/` are treated as different paths. If you want to
-change this, set this flag to `true`. That way, both `/foo` and `/foo/` will
-point to the same route. This option applies to *all* route registrations for
-the resulting server instance.
-
-```js
-const fastify = require('fastify')({
-  ignoreTrailingSlash: true
-})
-
-// registers both "/foo" and "/foo/"
-fastify.get('/foo/', function (req, reply) {
-  reply.send('foo')
-})
-
-// registers both "/bar" and "/bar/"
-fastify.get('/bar', function (req, reply) {
-  reply.send('bar')
-})
-```
-
-### `ignoreDuplicateSlashes`
-<a id="factory-ignore-duplicate-slashes"></a>
-
-+ Default: `false`
-
-Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) to handle
-routing. You can use `ignoreDuplicateSlashes` option to remove duplicate slashes
-from the path. It removes duplicate slashes in the route path and the request
-URL. This option applies to *all* route registrations for the resulting server
-instance.
-
-When `ignoreTrailingSlash` and `ignoreDuplicateSlashes` are both set
-to `true` Fastify will remove duplicate slashes, and then trailing slashes,
-meaning `//a//b//c//` will be converted to `/a/b/c`.
-
-```js
-const fastify = require('fastify')({
-  ignoreDuplicateSlashes: true
-})
-
-// registers "/foo/bar/"
-fastify.get('///foo//bar//', function (req, reply) {
-  reply.send('foo')
-})
-```
-
-### `maxParamLength`
-<a id="factory-max-param-length"></a>
-
-+ Default: `100`
-
-You can set a custom length for parameters in parametric (standard, regex, and
-multi) routes by using `maxParamLength` option; the default value is 100
-characters. If the maximum length limit is reached, the not found route will
-be invoked.
-
-This can be useful especially if you have a regex-based route, protecting you
-against [ReDoS
-attacks](https://www.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS).
-
 ### `bodyLimit`
 <a id="factory-body-limit"></a>
 
@@ -446,44 +382,6 @@ custom server you must be sure to have the same API exposed. If not, you can
 enhance the server instance inside the `serverFactory` function before the
 `return` statement.
 
-### `caseSensitive`
-<a id="factory-case-sensitive"></a>
-
-+ Default: `true`
-
-When `true` routes are registered as case-sensitive. That is, `/foo`
-is not equal to `/Foo`.
-When `false` then routes are case-insensitive.
-
-Please note that setting this option to `false` goes against
-[RFC3986](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1).
-
-By setting `caseSensitive` to `false`, all paths will be matched as lowercase,
-but the route parameters or wildcards will maintain their original letter
-casing.
-This option does not affect query strings, please refer to
-[`querystringParser`](#querystringparser) to change their handling.
-
-```js
-fastify.get('/user/:username', (request, reply) => {
-  // Given the URL: /USER/NodeJS
-  console.log(request.params.username) // -> 'NodeJS'
-})
-```
-
-### `allowUnsafeRegex`
-<a id="factory-allow-unsafe-regex"></a>
-
-+ Default `false`
-
-Disabled by default, so routes only allow safe regular expressions. To use
-unsafe expressions, set `allowUnsafeRegex` to `true`.
-
-```js
-fastify.get('/user/:id(^([0-9]+){4}$)', (request, reply) => {
-  // Throws an error without allowUnsafeRegex = true
-})
-```
 
 ### `requestIdHeader`
 <a id="factory-request-id-header"></a>
@@ -635,37 +533,6 @@ Automatically creates a sibling `HEAD` route for each `GET` route defined. If
 you want a custom `HEAD` handler without disabling this option, make sure to
 define it before the `GET` route.
 
-### `constraints`
-<a id="constraints"></a>
-
-Fastify's built-in route constraints are provided by `find-my-way`, which
-allows constraining routes by `version` or `host`. You can add new constraint
-strategies, or override the built-in strategies, by providing a `constraints`
-object with strategies for `find-my-way`. You can find more information on
-constraint strategies in the
-[find-my-way](https://github.com/delvedor/find-my-way) documentation.
-
-```js
-const customVersionStrategy = {
-  storage: function () {
-    const versions = {}
-    return {
-      get: (version) => { return versions[version] || null },
-      set: (version, store) => { versions[version] = store }
-    }
-  },
-  deriveVersion: (req, ctx) => {
-    return req.headers['accept']
-  }
-}
-
-const fastify = require('fastify')({
-  constraints: {
-    version: customVersionStrategy
-  }
-})
-```
-
 ### `return503OnClosing`
 <a id="factory-return-503-on-closing"></a>
 
@@ -842,6 +709,254 @@ function rewriteUrl (req) {
 }
 ```
 
+## RouterOptions
+<a id="routeroptions"></a>
+
+Fastify uses [`find-my-way`](https://github.com/delvedor/find-my-way) for its
+HTTP router. The `routerOptions` parameter allows passing 
+[`find-my-way` options](https://github.com/delvedor/find-my-way?tab=readme-ov-file#findmywayoptions)
+to customize the HTTP router within Fastify.
+
+### `allowUnsafeRegex`
+<a id="allow-unsafe-regex"></a>
+
++ Default `false`
+
+Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) which is,
+disabled by default, so routes only allow safe regular expressions. To use
+unsafe expressions, set `allowUnsafeRegex` to `true`.
+
+```js
+fastify.get('/user/:id(^([0-9]+){4}$)', (request, reply) => {
+  // Throws an error without allowUnsafeRegex = true
+})
+```
+
+
+### `buildPrettyMeta`
+<a id="build-pretty-meta"></a>
+
+Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) which
+supports, `buildPrettyMeta` where you can assign a `buildPrettyMeta` 
+function to sanitize a route's store object to use with the `prettyPrint` 
+functions. This function should accept a single object and return an object.
+
+```js
+fastify.get('/user/:username', (request, reply) => {
+  routerOptions: {
+    buildPrettyMeta: route => {
+      const cleanMeta = Object.assign({}, route.store)
+
+      // remove private properties
+      Object.keys(cleanMeta).forEach(k => {
+        if (typeof k === 'symbol') delete cleanMeta[k]
+      })
+
+      return cleanMeta // this will show up in the pretty print output!
+    })
+  }
+})
+```
+
+### `caseSensitive`
+<a id="case-sensitive"></a>
+
++ Default: `true`
+
+When `true` routes are registered as case-sensitive. That is, `/foo`
+is not equal to `/Foo`.
+When `false` then routes are case-insensitive.
+
+Please note that setting this option to `false` goes against
+[RFC3986](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1).
+
+By setting `caseSensitive` to `false`, all paths will be matched as lowercase,
+but the route parameters or wildcards will maintain their original letter
+casing.
+This option does not affect query strings, please refer to
+[`querystringParser`](#querystringparser) to change their handling.
+
+```js
+fastify.get('/user/:username', (request, reply) => {
+  // Given the URL: /USER/NodeJS
+  console.log(request.params.username) // -> 'NodeJS'
+})
+```
+
+### `constraints`
+<a id="constraints"></a>
+
+Fastify's built-in route constraints are provided by `find-my-way`, which
+allows constraining routes by `version` or `host`. You can add new constraint
+strategies, or override the built-in strategies, by providing a `constraints`
+object with strategies for `find-my-way`. You can find more information on
+constraint strategies in the
+[find-my-way](https://github.com/delvedor/find-my-way) documentation.
+
+```js
+const customVersionStrategy = {
+  storage: function () {
+    const versions = {}
+    return {
+      get: (version) => { return versions[version] || null },
+      set: (version, store) => { versions[version] = store }
+    }
+  },
+  deriveVersion: (req, ctx) => {
+    return req.headers['accept']
+  }
+}
+
+const fastify = require('fastify')({
+  routerOptions: {
+    constraints: {
+      version: customVersionStrategy
+    }
+  }
+})
+```
+
+### `defaultRoute`
+<a id="on-bad-url"></a>
+
+Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) which supports,
+can pass a default route with the option defaultRoute.
+
+```js
+const fastify = require('fastify')({
+  routerOptions: {
+    defaultRoute: (req, res) => {
+      res.statusCode = 404
+      res.end()
+    }
+  }
+})
+```
+
+### `ignoreDuplicateSlashes`
+<a id="factory-ignore-duplicate-slashes"></a>
+
++ Default: `false`
+
+Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) to handle
+routing. You can use `ignoreDuplicateSlashes` option to remove duplicate slashes
+from the path. It removes duplicate slashes in the route path and the request
+URL. This option applies to *all* route registrations for the resulting server
+instance.
+
+When `ignoreTrailingSlash` and `ignoreDuplicateSlashes` are both set
+to `true` Fastify will remove duplicate slashes, and then trailing slashes,
+meaning `//a//b//c//` will be converted to `/a/b/c`.
+
+```js
+const fastify = require('fastify')({
+  routerOptions: {
+    ignoreDuplicateSlashes: true
+  }
+})
+
+// registers "/foo/bar/"
+fastify.get('///foo//bar//', function (req, reply) {
+  reply.send('foo')
+})
+```
+
+### `ignoreTrailingSlash`
+<a id="ignore-slash"></a>
+
++ Default: `false`
+
+Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) to handle
+routing. By default, Fastify will take into account the trailing slashes.
+Paths like `/foo` and `/foo/` are treated as different paths. If you want to
+change this, set this flag to `true`. That way, both `/foo` and `/foo/` will
+point to the same route. This option applies to *all* route registrations for
+the resulting server instance.
+
+```js
+const fastify = require('fastify')({
+  routerOptions: {
+    ignoreTrailingSlash: true
+  }
+})
+
+// registers both "/foo" and "/foo/"
+fastify.get('/foo/', function (req, reply) {
+  reply.send('foo')
+})
+
+// registers both "/bar" and "/bar/"
+fastify.get('/bar', function (req, reply) {
+  reply.send('bar')
+})
+```
+
+### `maxParamLength`
+<a id="max-param-length"></a>
+
++ Default: `100`
+
+You can set a custom length for parameters in parametric (standard, regex, and
+multi) routes by using `maxParamLength` option; the default value is 100
+characters. If the maximum length limit is reached, the not found route will
+be invoked.
+
+This can be useful especially if you have a regex-based route, protecting you
+against [ReDoS
+attacks](https://www.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS).
+
+
+### `onBadUrl`
+<a id="on-bad-url"></a>
+
+Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) which supports,
+the use case of a badly formatted url (eg: /hello/%world), by default find-my-way
+will invoke the defaultRoute, unless you specify the onBadUrl option.
+
+```js
+const fastify = require('fastify')({
+  routerOptions: {
+    onBadUrl: (path, req, res) => {
+      res.statusCode = 400
+      res.end(`Bad path: ${path}`)
+    }
+  }
+})
+```
+
+### `querystringParser`
+<a id="querystringparser"></a>
+
+The default query string parser that Fastify uses is the Node.js's core
+`querystring` module.
+
+You can use this option to use a custom parser, such as
+[`qs`](https://www.npmjs.com/package/qs).
+
+If you only want the keys (and not the values) to be case insensitive we
+recommend using a custom parser to convert only the keys to lowercase.
+
+```js
+const qs = require('qs')
+const fastify = require('fastify')({
+  routerOptions: {
+    querystringParser: str => qs.parse(str)
+  }
+})
+```
+
+You can also use Fastify's default parser but change some handling behavior,
+like the example below for case insensitive keys and values:
+
+```js
+const querystring = require('node:querystring')
+const fastify = require('fastify')({
+  routerOptions: {
+    querystringParser: str => querystring.parse(str.toLowerCase())
+  }
+})
+```
+
 ### `useSemicolonDelimiter`
 <a id="use-semicolon-delimiter"></a>
 
@@ -856,7 +971,9 @@ on `;` set `useSemicolonDelimiter` to `true`.
 
 ```js
 const fastify = require('fastify')({
-  useSemicolonDelimiter: true
+  routerOptions: {
+    useSemicolonDelimiter: true
+  }
 })
 
 fastify.get('/dev', async (request, reply) => {
@@ -1985,20 +2102,28 @@ The properties that can currently be exposed are:
 - keepAliveTimeout
 - bodyLimit
 - caseSensitive
-- allowUnsafeRegex
 - http2
 - https (it will return `false`/`true` or `{ allowHTTP1: true/false }` if
   explicitly passed)
-- ignoreTrailingSlash
 - disableRequestLogging
-- maxParamLength
 - onProtoPoisoning
 - onConstructorPoisoning
 - pluginTimeout
 - requestIdHeader
 - requestIdLogLabel
 - http2SessionTimeout
-- useSemicolonDelimiter
+- routerOptions
+  - allowUnsafeRegex
+  - buildPrettyMeta
+  - caseSensitive
+  - constraints
+  - defaultRoute
+  - ignoreDuplicateSlashes
+  - ignoreTrailingSlash
+  - maxParamLength
+  - onBadUrl
+  - querystringParser
+  - useSemicolonDelimiter
 
 ```js
 const { readFileSync } = require('node:fs')
@@ -2011,9 +2136,11 @@ const fastify = Fastify({
     cert: readFileSync('./fastify.cert')
   },
   logger: { level: 'trace'},
-  ignoreTrailingSlash: true,
-  maxParamLength: 200,
-  caseSensitive: true,
+  routerOptions: {
+    ignoreTrailingSlash: true,
+    maxParamLength: 200,
+    caseSensitive: true,
+  },
   trustProxy: '127.0.0.1,192.168.1.1/24',
 })
 
@@ -2021,10 +2148,12 @@ console.log(fastify.initialConfig)
 /*
 will log :
 {
-  caseSensitive: true,
   https: { allowHTTP1: true },
-  ignoreTrailingSlash: true,
-  maxParamLength: 200
+  routerOptions: {
+    caseSensitive: true,
+    ignoreTrailingSlash: true,
+    maxParamLength: 200
+  }
 }
 */
 
@@ -2034,10 +2163,12 @@ fastify.register(async (instance, opts) => {
     /*
     will return :
     {
-      caseSensitive: true,
       https: { allowHTTP1: true },
-      ignoreTrailingSlash: true,
-      maxParamLength: 200
+      routerOptions: {
+        caseSensitive: true,
+        ignoreTrailingSlash: true,
+        maxParamLength: 200
+      }
     }
     */
   })
diff --git a/docs/Reference/Warnings.md b/docs/Reference/Warnings.md
index 165baa44775..d07b23d1705 100644
--- a/docs/Reference/Warnings.md
+++ b/docs/Reference/Warnings.md
@@ -8,7 +8,7 @@
     - [FSTWRN001](#FSTWRN001)
     - [FSTWRN002](#FSTWRN002)
   - [Fastify Deprecation Codes](#fastify-deprecation-codes)
-
+    - [FSTDEP022](#FSTDEP022)
 
 ## Warnings
 
@@ -55,3 +55,4 @@ Deprecation codes are supported by the Node.js CLI options:
 
 | Code | Description | How to solve | Discussion |
 | ---- | ----------- | ------------ | ---------- |
+| <a id="FSTDEP022">FSTDEP022</a> | You are trying to access the deprecated router options on top option properties. | Use `options.routerOptions`. | [#5985](https://github.com/fastify/fastify/pull/5985)
diff --git a/fastify.js b/fastify.js
index eda58074878..0bb20a08189 100644
--- a/fastify.js
+++ b/fastify.js
@@ -46,7 +46,7 @@ const { Hooks, hookRunnerApplication, supportedHooks } = require('./lib/hooks')
 const { createChildLogger, defaultChildLoggerFactory, createLogger } = require('./lib/logger-factory')
 const pluginUtils = require('./lib/pluginUtils')
 const { getGenReqId, reqIdGenFactory } = require('./lib/reqIdGenFactory')
-const { buildRouting, validateBodyLimitOption } = require('./lib/route')
+const { buildRouting, validateBodyLimitOption, buildRouterOptions } = require('./lib/route')
 const build404 = require('./lib/fourOhFour')
 const getSecuredInitialConfig = require('./lib/initialConfigValidation')
 const override = require('./lib/pluginOverride')
@@ -108,8 +108,14 @@ function fastify (options) {
     options = Object.assign({}, options)
   }
 
-  if (options.querystringParser && typeof options.querystringParser !== 'function') {
-    throw new FST_ERR_QSP_NOT_FN(typeof options.querystringParser)
+  if (
+    (options.querystringParser && typeof options.querystringParser !== 'function') ||
+    (
+      options.routerOptions?.querystringParser &&
+      typeof options.routerOptions.querystringParser !== 'function'
+    )
+  ) {
+    throw new FST_ERR_QSP_NOT_FN(typeof (options.querystringParser ?? options.routerOptions.querystringParser))
   }
 
   if (options.schemaController && options.schemaController.bucket && typeof options.schemaController.bucket !== 'function') {
@@ -160,21 +166,20 @@ function fastify (options) {
   // exposeHeadRoutes have its default set from the validator
   options.exposeHeadRoutes = initialConfig.exposeHeadRoutes
 
+  options.routerOptions = buildRouterOptions(options, {
+    defaultRoute,
+    onBadUrl,
+    ignoreTrailingSlash: defaultInitOptions.ignoreTrailingSlash,
+    ignoreDuplicateSlashes: defaultInitOptions.ignoreDuplicateSlashes,
+    maxParamLength: defaultInitOptions.maxParamLength,
+    allowUnsafeRegex: defaultInitOptions.allowUnsafeRegex,
+    buildPrettyMeta: defaultBuildPrettyMeta,
+    useSemicolonDelimiter: defaultInitOptions.useSemicolonDelimiter
+  })
+
   // Default router
   const router = buildRouting({
-    config: {
-      defaultRoute,
-      onBadUrl,
-      constraints: options.constraints,
-      ignoreTrailingSlash: options.ignoreTrailingSlash || defaultInitOptions.ignoreTrailingSlash,
-      ignoreDuplicateSlashes: options.ignoreDuplicateSlashes || defaultInitOptions.ignoreDuplicateSlashes,
-      maxParamLength: options.maxParamLength || defaultInitOptions.maxParamLength,
-      caseSensitive: options.caseSensitive,
-      allowUnsafeRegex: options.allowUnsafeRegex || defaultInitOptions.allowUnsafeRegex,
-      buildPrettyMeta: defaultBuildPrettyMeta,
-      querystringParser: options.querystringParser,
-      useSemicolonDelimiter: options.useSemicolonDelimiter ?? defaultInitOptions.useSemicolonDelimiter
-    }
+    config: options.routerOptions
   })
 
   // 404 router, used for handling encapsulated 404 handlers
diff --git a/lib/configValidator.js b/lib/configValidator.js
index c2d842896dc..a110fb6bcb5 100644
--- a/lib/configValidator.js
+++ b/lib/configValidator.js
@@ -3,7 +3,7 @@
 "use strict";
 module.exports = validate10;
 module.exports.default = validate10;
-const schema11 = {"type":"object","additionalProperties":false,"properties":{"connectionTimeout":{"type":"integer","default":0},"keepAliveTimeout":{"type":"integer","default":72000},"forceCloseConnections":{"oneOf":[{"type":"string","pattern":"idle"},{"type":"boolean"}]},"maxRequestsPerSocket":{"type":"integer","default":0,"nullable":true},"requestTimeout":{"type":"integer","default":0},"bodyLimit":{"type":"integer","default":1048576},"caseSensitive":{"type":"boolean","default":true},"allowUnsafeRegex":{"type":"boolean","default":false},"http2":{"type":"boolean"},"https":{"if":{"not":{"oneOf":[{"type":"boolean"},{"type":"null"},{"type":"object","additionalProperties":false,"required":["allowHTTP1"],"properties":{"allowHTTP1":{"type":"boolean"}}}]}},"then":{"setDefaultValue":true}},"ignoreTrailingSlash":{"type":"boolean","default":false},"ignoreDuplicateSlashes":{"type":"boolean","default":false},"disableRequestLogging":{"type":"boolean","default":false},"maxParamLength":{"type":"integer","default":100},"onProtoPoisoning":{"type":"string","default":"error"},"onConstructorPoisoning":{"type":"string","default":"error"},"pluginTimeout":{"type":"integer","default":10000},"requestIdHeader":{"anyOf":[{"type":"boolean"},{"type":"string"}],"default":false},"requestIdLogLabel":{"type":"string","default":"reqId"},"http2SessionTimeout":{"type":"integer","default":72000},"exposeHeadRoutes":{"type":"boolean","default":true},"useSemicolonDelimiter":{"type":"boolean","default":false},"constraints":{"type":"object","additionalProperties":{"type":"object","required":["name","storage","validate","deriveConstraint"],"additionalProperties":true,"properties":{"name":{"type":"string"},"storage":{},"validate":{},"deriveConstraint":{}}}}}};
+const schema11 = {"type":"object","additionalProperties":false,"properties":{"connectionTimeout":{"type":"integer","default":0},"keepAliveTimeout":{"type":"integer","default":72000},"forceCloseConnections":{"oneOf":[{"type":"string","pattern":"idle"},{"type":"boolean"}]},"maxRequestsPerSocket":{"type":"integer","default":0,"nullable":true},"requestTimeout":{"type":"integer","default":0},"bodyLimit":{"type":"integer","default":1048576},"caseSensitive":{"type":"boolean","default":true},"allowUnsafeRegex":{"type":"boolean","default":false},"http2":{"type":"boolean"},"https":{"if":{"not":{"oneOf":[{"type":"boolean"},{"type":"null"},{"type":"object","additionalProperties":false,"required":["allowHTTP1"],"properties":{"allowHTTP1":{"type":"boolean"}}}]}},"then":{"setDefaultValue":true}},"ignoreTrailingSlash":{"type":"boolean","default":false},"ignoreDuplicateSlashes":{"type":"boolean","default":false},"disableRequestLogging":{"type":"boolean","default":false},"maxParamLength":{"type":"integer","default":100},"onProtoPoisoning":{"type":"string","default":"error"},"onConstructorPoisoning":{"type":"string","default":"error"},"pluginTimeout":{"type":"integer","default":10000},"requestIdHeader":{"anyOf":[{"type":"boolean"},{"type":"string"}],"default":false},"requestIdLogLabel":{"type":"string","default":"reqId"},"http2SessionTimeout":{"type":"integer","default":72000},"exposeHeadRoutes":{"type":"boolean","default":true},"useSemicolonDelimiter":{"type":"boolean","default":false},"routerOptions":{"type":"object","additionalProperties":false,"properties":{"ignoreTrailingSlash":{"type":"boolean","default":false},"ignoreDuplicateSlashes":{"type":"boolean","default":false},"maxParamLength":{"type":"integer","default":100},"allowUnsafeRegex":{"type":"boolean","default":false},"useSemicolonDelimiter":{"type":"boolean","default":false}}},"constraints":{"type":"object","additionalProperties":{"type":"object","required":["name","storage","validate","deriveConstraint"],"additionalProperties":true,"properties":{"name":{"type":"string"},"storage":{},"validate":{},"deriveConstraint":{}}}}}};
 const func2 = Object.prototype.hasOwnProperty;
 const pattern0 = new RegExp("idle", "u");
 
@@ -1002,43 +1002,210 @@ data["useSemicolonDelimiter"] = coerced25;
 }
 var valid0 = _errs67 === errors;
 if(valid0){
-if(data.constraints !== undefined){
-let data23 = data.constraints;
+if(data.routerOptions !== undefined){
+let data23 = data.routerOptions;
 const _errs69 = errors;
 if(errors === _errs69){
 if(data23 && typeof data23 == "object" && !Array.isArray(data23)){
+if(data23.ignoreTrailingSlash === undefined){
+data23.ignoreTrailingSlash = false;
+}
+if(data23.ignoreDuplicateSlashes === undefined){
+data23.ignoreDuplicateSlashes = false;
+}
+if(data23.maxParamLength === undefined){
+data23.maxParamLength = 100;
+}
+if(data23.allowUnsafeRegex === undefined){
+data23.allowUnsafeRegex = false;
+}
+if(data23.useSemicolonDelimiter === undefined){
+data23.useSemicolonDelimiter = false;
+}
+const _errs71 = errors;
 for(const key2 in data23){
-let data24 = data23[key2];
+if(!(((((key2 === "ignoreTrailingSlash") || (key2 === "ignoreDuplicateSlashes")) || (key2 === "maxParamLength")) || (key2 === "allowUnsafeRegex")) || (key2 === "useSemicolonDelimiter"))){
+delete data23[key2];
+}
+}
+if(_errs71 === errors){
+let data24 = data23.ignoreTrailingSlash;
 const _errs72 = errors;
-if(errors === _errs72){
-if(data24 && typeof data24 == "object" && !Array.isArray(data24)){
+if(typeof data24 !== "boolean"){
+let coerced26 = undefined;
+if(!(coerced26 !== undefined)){
+if(data24 === "false" || data24 === 0 || data24 === null){
+coerced26 = false;
+}
+else if(data24 === "true" || data24 === 1){
+coerced26 = true;
+}
+else {
+validate10.errors = [{instancePath:instancePath+"/routerOptions/ignoreTrailingSlash",schemaPath:"#/properties/routerOptions/properties/ignoreTrailingSlash/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];
+return false;
+}
+}
+if(coerced26 !== undefined){
+data24 = coerced26;
+if(data23 !== undefined){
+data23["ignoreTrailingSlash"] = coerced26;
+}
+}
+}
+var valid7 = _errs72 === errors;
+if(valid7){
+let data25 = data23.ignoreDuplicateSlashes;
+const _errs74 = errors;
+if(typeof data25 !== "boolean"){
+let coerced27 = undefined;
+if(!(coerced27 !== undefined)){
+if(data25 === "false" || data25 === 0 || data25 === null){
+coerced27 = false;
+}
+else if(data25 === "true" || data25 === 1){
+coerced27 = true;
+}
+else {
+validate10.errors = [{instancePath:instancePath+"/routerOptions/ignoreDuplicateSlashes",schemaPath:"#/properties/routerOptions/properties/ignoreDuplicateSlashes/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];
+return false;
+}
+}
+if(coerced27 !== undefined){
+data25 = coerced27;
+if(data23 !== undefined){
+data23["ignoreDuplicateSlashes"] = coerced27;
+}
+}
+}
+var valid7 = _errs74 === errors;
+if(valid7){
+let data26 = data23.maxParamLength;
+const _errs76 = errors;
+if(!(((typeof data26 == "number") && (!(data26 % 1) && !isNaN(data26))) && (isFinite(data26)))){
+let dataType28 = typeof data26;
+let coerced28 = undefined;
+if(!(coerced28 !== undefined)){
+if(dataType28 === "boolean" || data26 === null
+              || (dataType28 === "string" && data26 && data26 == +data26 && !(data26 % 1))){
+coerced28 = +data26;
+}
+else {
+validate10.errors = [{instancePath:instancePath+"/routerOptions/maxParamLength",schemaPath:"#/properties/routerOptions/properties/maxParamLength/type",keyword:"type",params:{type: "integer"},message:"must be integer"}];
+return false;
+}
+}
+if(coerced28 !== undefined){
+data26 = coerced28;
+if(data23 !== undefined){
+data23["maxParamLength"] = coerced28;
+}
+}
+}
+var valid7 = _errs76 === errors;
+if(valid7){
+let data27 = data23.allowUnsafeRegex;
+const _errs78 = errors;
+if(typeof data27 !== "boolean"){
+let coerced29 = undefined;
+if(!(coerced29 !== undefined)){
+if(data27 === "false" || data27 === 0 || data27 === null){
+coerced29 = false;
+}
+else if(data27 === "true" || data27 === 1){
+coerced29 = true;
+}
+else {
+validate10.errors = [{instancePath:instancePath+"/routerOptions/allowUnsafeRegex",schemaPath:"#/properties/routerOptions/properties/allowUnsafeRegex/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];
+return false;
+}
+}
+if(coerced29 !== undefined){
+data27 = coerced29;
+if(data23 !== undefined){
+data23["allowUnsafeRegex"] = coerced29;
+}
+}
+}
+var valid7 = _errs78 === errors;
+if(valid7){
+let data28 = data23.useSemicolonDelimiter;
+const _errs80 = errors;
+if(typeof data28 !== "boolean"){
+let coerced30 = undefined;
+if(!(coerced30 !== undefined)){
+if(data28 === "false" || data28 === 0 || data28 === null){
+coerced30 = false;
+}
+else if(data28 === "true" || data28 === 1){
+coerced30 = true;
+}
+else {
+validate10.errors = [{instancePath:instancePath+"/routerOptions/useSemicolonDelimiter",schemaPath:"#/properties/routerOptions/properties/useSemicolonDelimiter/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];
+return false;
+}
+}
+if(coerced30 !== undefined){
+data28 = coerced30;
+if(data23 !== undefined){
+data23["useSemicolonDelimiter"] = coerced30;
+}
+}
+}
+var valid7 = _errs80 === errors;
+}
+}
+}
+}
+}
+}
+else {
+validate10.errors = [{instancePath:instancePath+"/routerOptions",schemaPath:"#/properties/routerOptions/type",keyword:"type",params:{type: "object"},message:"must be object"}];
+return false;
+}
+}
+var valid0 = _errs69 === errors;
+}
+else {
+var valid0 = true;
+}
+if(valid0){
+if(data.constraints !== undefined){
+let data29 = data.constraints;
+const _errs82 = errors;
+if(errors === _errs82){
+if(data29 && typeof data29 == "object" && !Array.isArray(data29)){
+for(const key3 in data29){
+let data30 = data29[key3];
+const _errs85 = errors;
+if(errors === _errs85){
+if(data30 && typeof data30 == "object" && !Array.isArray(data30)){
 let missing1;
-if(((((data24.name === undefined) && (missing1 = "name")) || ((data24.storage === undefined) && (missing1 = "storage"))) || ((data24.validate === undefined) && (missing1 = "validate"))) || ((data24.deriveConstraint === undefined) && (missing1 = "deriveConstraint"))){
-validate10.errors = [{instancePath:instancePath+"/constraints/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/constraints/additionalProperties/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}];
+if(((((data30.name === undefined) && (missing1 = "name")) || ((data30.storage === undefined) && (missing1 = "storage"))) || ((data30.validate === undefined) && (missing1 = "validate"))) || ((data30.deriveConstraint === undefined) && (missing1 = "deriveConstraint"))){
+validate10.errors = [{instancePath:instancePath+"/constraints/" + key3.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/constraints/additionalProperties/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}];
 return false;
 }
 else {
-if(data24.name !== undefined){
-let data25 = data24.name;
-if(typeof data25 !== "string"){
-let dataType26 = typeof data25;
-let coerced26 = undefined;
-if(!(coerced26 !== undefined)){
-if(dataType26 == "number" || dataType26 == "boolean"){
-coerced26 = "" + data25;
+if(data30.name !== undefined){
+let data31 = data30.name;
+if(typeof data31 !== "string"){
+let dataType31 = typeof data31;
+let coerced31 = undefined;
+if(!(coerced31 !== undefined)){
+if(dataType31 == "number" || dataType31 == "boolean"){
+coerced31 = "" + data31;
 }
-else if(data25 === null){
-coerced26 = "";
+else if(data31 === null){
+coerced31 = "";
 }
 else {
-validate10.errors = [{instancePath:instancePath+"/constraints/" + key2.replace(/~/g, "~0").replace(/\//g, "~1")+"/name",schemaPath:"#/properties/constraints/additionalProperties/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"}];
+validate10.errors = [{instancePath:instancePath+"/constraints/" + key3.replace(/~/g, "~0").replace(/\//g, "~1")+"/name",schemaPath:"#/properties/constraints/additionalProperties/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"}];
 return false;
 }
 }
-if(coerced26 !== undefined){
-data25 = coerced26;
-if(data24 !== undefined){
-data24["name"] = coerced26;
+if(coerced31 !== undefined){
+data31 = coerced31;
+if(data30 !== undefined){
+data30["name"] = coerced31;
 }
 }
 }
@@ -1046,12 +1213,12 @@ data24["name"] = coerced26;
 }
 }
 else {
-validate10.errors = [{instancePath:instancePath+"/constraints/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/constraints/additionalProperties/type",keyword:"type",params:{type: "object"},message:"must be object"}];
+validate10.errors = [{instancePath:instancePath+"/constraints/" + key3.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/constraints/additionalProperties/type",keyword:"type",params:{type: "object"},message:"must be object"}];
 return false;
 }
 }
-var valid7 = _errs72 === errors;
-if(!valid7){
+var valid8 = _errs85 === errors;
+if(!valid8){
 break;
 }
 }
@@ -1061,7 +1228,7 @@ validate10.errors = [{instancePath:instancePath+"/constraints",schemaPath:"#/pro
 return false;
 }
 }
-var valid0 = _errs69 === errors;
+var valid0 = _errs82 === errors;
 }
 else {
 var valid0 = true;
@@ -1090,6 +1257,7 @@ var valid0 = true;
 }
 }
 }
+}
 else {
 validate10.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}];
 return false;
@@ -1100,5 +1268,5 @@ return errors === 0;
 }
 
 
-module.exports.defaultInitOptions = {"connectionTimeout":0,"keepAliveTimeout":72000,"maxRequestsPerSocket":0,"requestTimeout":0,"bodyLimit":1048576,"caseSensitive":true,"allowUnsafeRegex":false,"disableRequestLogging":false,"ignoreTrailingSlash":false,"ignoreDuplicateSlashes":false,"maxParamLength":100,"onProtoPoisoning":"error","onConstructorPoisoning":"error","pluginTimeout":10000,"requestIdHeader":false,"requestIdLogLabel":"reqId","http2SessionTimeout":72000,"exposeHeadRoutes":true,"useSemicolonDelimiter":false,"allowErrorHandlerOverride":true}
+module.exports.defaultInitOptions = {"connectionTimeout":0,"keepAliveTimeout":72000,"maxRequestsPerSocket":0,"requestTimeout":0,"bodyLimit":1048576,"caseSensitive":true,"allowUnsafeRegex":false,"disableRequestLogging":false,"ignoreTrailingSlash":false,"ignoreDuplicateSlashes":false,"maxParamLength":100,"onProtoPoisoning":"error","onConstructorPoisoning":"error","pluginTimeout":10000,"requestIdHeader":false,"requestIdLogLabel":"reqId","http2SessionTimeout":72000,"exposeHeadRoutes":true,"useSemicolonDelimiter":false,"allowErrorHandlerOverride":true,"routerOptions":{"ignoreTrailingSlash":false,"ignoreDuplicateSlashes":false,"maxParamLength":100,"allowUnsafeRegex":false,"useSemicolonDelimiter":false}}
 /* c8 ignore stop */
diff --git a/lib/route.js b/lib/route.js
index d396a02d1b0..d5e5d832a81 100644
--- a/lib/route.js
+++ b/lib/route.js
@@ -29,6 +29,8 @@ const {
   FST_ERR_HOOK_INVALID_ASYNC_HANDLER
 } = require('./errors')
 
+const { FSTDEP022 } = require('./warnings')
+
 const {
   kRoutePrefix,
   kSupportedHTTPMethods,
@@ -52,6 +54,20 @@ const { buildErrorHandler } = require('./error-handler')
 const { createChildLogger } = require('./logger-factory.js')
 const { getGenReqId } = require('./reqIdGenFactory.js')
 
+const routerKeys = [
+  'allowUnsafeRegex',
+  'buildPrettyMeta',
+  'caseSensitive',
+  'constraints',
+  'defaultRoute',
+  'ignoreDuplicateSlashes',
+  'ignoreTrailingSlash',
+  'maxParamLength',
+  'onBadUrl',
+  'querystringParser',
+  'useSemicolonDelimiter'
+]
+
 function buildRouting (options) {
   const router = FindMyWay(options.config)
 
@@ -85,8 +101,8 @@ function buildRouting (options) {
 
       globalExposeHeadRoutes = options.exposeHeadRoutes
       disableRequestLogging = options.disableRequestLogging
-      ignoreTrailingSlash = options.ignoreTrailingSlash
-      ignoreDuplicateSlashes = options.ignoreDuplicateSlashes
+      ignoreTrailingSlash = options.routerOptions.ignoreTrailingSlash
+      ignoreDuplicateSlashes = options.routerOptions.ignoreDuplicateSlashes
       return503OnClosing = Object.hasOwn(options, 'return503OnClosing') ? options.return503OnClosing : true
       keepAliveConnections = fastifyArgs.keepAliveConnections
     },
@@ -572,6 +588,24 @@ function runPreParsing (err, request, reply) {
   }
 }
 
+function buildRouterOptions (options, defaultOptions) {
+  const routerOptions = options.routerOptions || Object.create(null)
+
+  const usedDeprecatedOptions = routerKeys.filter(key => Object.hasOwn(options, key))
+
+  if (usedDeprecatedOptions.length > 0) {
+    FSTDEP022(usedDeprecatedOptions.join(', '))
+  }
+
+  for (const key of routerKeys) {
+    if (!Object.hasOwn(routerOptions, key)) {
+      routerOptions[key] = options[key] ?? defaultOptions[key]
+    }
+  }
+
+  return routerOptions
+}
+
 /**
  * Used within the route handler as a `net.Socket.close` event handler.
  * The purpose is to remove a socket from the tracked sockets collection when
@@ -583,4 +617,4 @@ function removeTrackedSocket () {
 
 function noop () { }
 
-module.exports = { buildRouting, validateBodyLimitOption }
+module.exports = { buildRouting, validateBodyLimitOption, buildRouterOptions }
diff --git a/lib/warnings.js b/lib/warnings.js
index 07a620e3991..f79f342e48e 100644
--- a/lib/warnings.js
+++ b/lib/warnings.js
@@ -6,8 +6,10 @@ const { createWarning } = require('process-warning')
  * Deprecation codes:
  *   - FSTWRN001
  *   - FSTSEC001
+ *   - FSTDEP022
  *
  * Deprecation Codes FSTDEP001 - FSTDEP021 were used by v4 and MUST NOT not be reused.
+ *                             - FSTDEP022 is used by v5 and MUST NOT be reused.
  * Warning Codes FSTWRN001 - FSTWRN002 were used by v4 and MUST NOT not be reused.
  */
 
@@ -39,9 +41,17 @@ const FSTSEC001 = createWarning({
   unlimited: true
 })
 
+const FSTDEP022 = createWarning({
+  name: 'FastifyWarning',
+  code: 'FSTDPE022',
+  message: 'The router options for %s property access is deprecated. Please use "options.routerOptions" instead for accessing router options. The router options will be removed in `fastify@6`.',
+  unlimited: true
+})
+
 module.exports = {
   FSTWRN001,
   FSTWRN003,
   FSTWRN004,
-  FSTSEC001
+  FSTSEC001,
+  FSTDEP022
 }


Test patch:
diff --git a/test/constrained-routes.test.js b/test/constrained-routes.test.js
index 6e5d8a9222f..b8b057ae2b9 100644
--- a/test/constrained-routes.test.js
+++ b/test/constrained-routes.test.js
@@ -905,3 +905,234 @@ test('Allow regex constraints in routes', async t => {
     t.assert.strictEqual(res.statusCode, 404)
   }
 })
+
+test('Should allow registering custom rotuerOptions constrained routes', async t => {
+  t.plan(5)
+
+  const constraint = {
+    name: 'secret',
+    storage: function () {
+      const secrets = {}
+      return {
+        get: (secret) => { return secrets[secret] || null },
+        set: (secret, store) => { secrets[secret] = store }
+      }
+    },
+    deriveConstraint: (req, ctx) => {
+      return req.headers['x-secret']
+    },
+    validate () { return true }
+  }
+
+  const fastify = Fastify({ routerOptions: { constraints: { secret: constraint } } })
+
+  fastify.route({
+    method: 'GET',
+    url: '/',
+    constraints: { secret: 'alpha' },
+    handler: (req, reply) => {
+      reply.send({ hello: 'from alpha' })
+    }
+  })
+
+  fastify.route({
+    method: 'GET',
+    url: '/',
+    constraints: { secret: 'beta' },
+    handler: (req, reply) => {
+      reply.send({ hello: 'from beta' })
+    }
+  })
+
+  {
+    const res = await fastify.inject({
+      method: 'GET',
+      url: '/',
+      headers: {
+        'X-Secret': 'alpha'
+      }
+    })
+    t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from alpha' })
+    t.assert.strictEqual(res.statusCode, 200)
+  }
+
+  {
+    const res = await fastify.inject({
+      method: 'GET',
+      url: '/',
+      headers: {
+        'X-Secret': 'beta'
+      }
+    })
+    t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from beta' })
+    t.assert.strictEqual(res.statusCode, 200)
+  }
+
+  {
+    const res = await fastify.inject({
+      method: 'GET',
+      url: '/',
+      headers: {
+        'X-Secret': 'gamma'
+      }
+    })
+    t.assert.strictEqual(res.statusCode, 404)
+  }
+})
+
+test('Custom rotuerOptions constrained routes registered also for HEAD method generated by fastify', (t, done) => {
+  t.plan(3)
+
+  const constraint = {
+    name: 'secret',
+    storage: function () {
+      const secrets = {}
+      return {
+        get: (secret) => { return secrets[secret] || null },
+        set: (secret, store) => { secrets[secret] = store }
+      }
+    },
+    deriveConstraint: (req, ctx) => {
+      return req.headers['x-secret']
+    },
+    validate () { return true }
+  }
+
+  const fastify = Fastify({ routerOptions: { constraints: { secret: constraint } } })
+
+  fastify.route({
+    method: 'GET',
+    url: '/',
+    constraints: { secret: 'mySecret' },
+    handler: (req, reply) => {
+      reply.send('from mySecret - my length is 31')
+    }
+  })
+
+  fastify.inject({
+    method: 'HEAD',
+    url: '/',
+    headers: {
+      'X-Secret': 'mySecret'
+    }
+  }, (err, res) => {
+    t.assert.ifError(err)
+    t.assert.deepStrictEqual(res.headers['content-length'], '31')
+    t.assert.strictEqual(res.statusCode, 200)
+    done()
+  })
+})
+
+test('allow async rotuerOptions constraints', async (t) => {
+  t.plan(5)
+
+  const constraint = {
+    name: 'secret',
+    storage: function () {
+      const secrets = {}
+      return {
+        get: (secret) => { return secrets[secret] || null },
+        set: (secret, store) => { secrets[secret] = store }
+      }
+    },
+    deriveConstraint: (req, ctx, done) => {
+      done(null, req.headers['x-secret'])
+    },
+    validate () { return true }
+  }
+
+  const fastify = Fastify({ routerOptions: { constraints: { secret: constraint } } })
+
+  fastify.route({
+    method: 'GET',
+    url: '/',
+    constraints: { secret: 'alpha' },
+    handler: (req, reply) => {
+      reply.send({ hello: 'from alpha' })
+    }
+  })
+
+  fastify.route({
+    method: 'GET',
+    url: '/',
+    constraints: { secret: 'beta' },
+    handler: (req, reply) => {
+      reply.send({ hello: 'from beta' })
+    }
+  })
+
+  {
+    const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'alpha' } })
+    t.assert.deepStrictEqual(JSON.parse(payload), { hello: 'from alpha' })
+    t.assert.strictEqual(statusCode, 200)
+  }
+  {
+    const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'beta' } })
+    t.assert.deepStrictEqual(JSON.parse(payload), { hello: 'from beta' })
+    t.assert.strictEqual(statusCode, 200)
+  }
+  {
+    const { statusCode } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'gamma' } })
+    t.assert.strictEqual(statusCode, 404)
+  }
+})
+
+test('error in async rotuerOptions constraints', async (t) => {
+  t.plan(8)
+
+  const constraint = {
+    name: 'secret',
+    storage: function () {
+      const secrets = {}
+      return {
+        get: (secret) => { return secrets[secret] || null },
+        set: (secret, store) => { secrets[secret] = store }
+      }
+    },
+    deriveConstraint: (req, ctx, done) => {
+      done(Error('kaboom'))
+    },
+    validate () { return true }
+  }
+
+  const fastify = Fastify({ routerOptions: { constraints: { secret: constraint } } })
+
+  fastify.route({
+    method: 'GET',
+    url: '/',
+    constraints: { secret: 'alpha' },
+    handler: (req, reply) => {
+      reply.send({ hello: 'from alpha' })
+    }
+  })
+
+  fastify.route({
+    method: 'GET',
+    url: '/',
+    constraints: { secret: 'beta' },
+    handler: (req, reply) => {
+      reply.send({ hello: 'from beta' })
+    }
+  })
+
+  {
+    const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'alpha' } })
+    t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 })
+    t.assert.strictEqual(statusCode, 500)
+  }
+  {
+    const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'beta' } })
+    t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 })
+    t.assert.strictEqual(statusCode, 500)
+  }
+  {
+    const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'gamma' } })
+    t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 })
+    t.assert.strictEqual(statusCode, 500)
+  }
+  {
+    const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/' })
+    t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 })
+    t.assert.strictEqual(statusCode, 500)
+  }
+})
diff --git a/test/custom-querystring-parser.test.js b/test/custom-querystring-parser.test.js
index 2f24a0d27db..a6f8a24d8d1 100644
--- a/test/custom-querystring-parser.test.js
+++ b/test/custom-querystring-parser.test.js
@@ -109,3 +109,21 @@ test('Custom querystring parser should be a function', t => {
     )
   }
 })
+
+test('Custom querystring parser should be a function', t => {
+  t.plan(1)
+
+  try {
+    Fastify({
+      routerOptions: {
+        querystringParser: 10
+      }
+    })
+    t.fail('Should throw')
+  } catch (err) {
+    t.assert.equal(
+      err.message,
+      "querystringParser option should be a function, instead got 'number'"
+    )
+  }
+})
diff --git a/test/router-options.test.js b/test/router-options.test.js
index e575a76a994..ad5fafd57eb 100644
--- a/test/router-options.test.js
+++ b/test/router-options.test.js
@@ -2,6 +2,7 @@
 
 const split = require('split2')
 const { test } = require('node:test')
+const querystring = require('node:querystring')
 const Fastify = require('../')
 const {
   FST_ERR_BAD_URL,
@@ -445,3 +446,452 @@ test('Should honor disableRequestLogging option in frameworkErrors wrapper - FST
     }
   )
 })
+
+test('Should honor routerOptions.defaultRoute', async t => {
+  t.plan(3)
+  const fastify = Fastify({
+    routerOptions: {
+      defaultRoute: function (_, res) {
+        t.assert.ok('default route called')
+        res.statusCode = 404
+        res.end('default route')
+      }
+    }
+  })
+
+  const res = await fastify.inject('/')
+  t.assert.strictEqual(res.statusCode, 404)
+  t.assert.strictEqual(res.payload, 'default route')
+})
+
+test('Should honor routerOptions.badUrl', async t => {
+  t.plan(3)
+  const fastify = Fastify({
+    routerOptions: {
+      defaultRoute: function (_, res) {
+        t.asset.fail('default route should not be called')
+      },
+      onBadUrl: function (path, _, res) {
+        t.assert.ok('bad url called')
+        res.statusCode = 400
+        res.end(`Bath URL: ${path}`)
+      }
+    }
+  })
+
+  fastify.get('/hello/:id', (req, res) => {
+    res.send({ hello: 'world' })
+  })
+
+  const res = await fastify.inject('/hello/%world')
+  t.assert.strictEqual(res.statusCode, 400)
+  t.assert.strictEqual(res.payload, 'Bath URL: /hello/%world')
+})
+
+test('Should honor routerOptions.ignoreTrailingSlash', async t => {
+  t.plan(4)
+  const fastify = Fastify({
+    routerOptions: {
+      ignoreTrailingSlash: true
+    }
+  })
+
+  fastify.get('/test', (req, res) => {
+    res.send('test')
+  })
+
+  let res = await fastify.inject('/test')
+  t.assert.strictEqual(res.statusCode, 200)
+  t.assert.strictEqual(res.payload.toString(), 'test')
+
+  res = await fastify.inject('/test/')
+  t.assert.strictEqual(res.statusCode, 200)
+  t.assert.strictEqual(res.payload.toString(), 'test')
+})
+
+test('Should honor routerOptions.ignoreDuplicateSlashes', async t => {
+  t.plan(4)
+  const fastify = Fastify({
+    routerOptions: {
+      ignoreDuplicateSlashes: true
+    }
+  })
+
+  fastify.get('/test//test///test', (req, res) => {
+    res.send('test')
+  })
+
+  let res = await fastify.inject('/test/test/test')
+  t.assert.strictEqual(res.statusCode, 200)
+  t.assert.strictEqual(res.payload.toString(), 'test')
+
+  res = await fastify.inject('/test//test///test')
+  t.assert.strictEqual(res.statusCode, 200)
+  t.assert.strictEqual(res.payload.toString(), 'test')
+})
+
+test('Should honor routerOptions.ignoreTrailingSlash and routerOptions.ignoreDuplicateSlashes', async t => {
+  t.plan(4)
+  const fastify = Fastify({
+    routerOptions: {
+      ignoreTrailingSlash: true,
+      ignoreDuplicateSlashes: true
+    }
+  })
+
+  t.after(() => fastify.close())
+
+  fastify.get('/test//test///test', (req, res) => {
+    res.send('test')
+  })
+
+  let res = await fastify.inject('/test/test/test/')
+  t.assert.strictEqual(res.statusCode, 200)
+  t.assert.strictEqual(res.payload.toString(), 'test')
+
+  res = await fastify.inject('/test//test///test//')
+  t.assert.strictEqual(res.statusCode, 200)
+  t.assert.strictEqual(res.payload.toString(), 'test')
+})
+
+test('Should honor routerOptions.maxParamLength', async (t) => {
+  const fastify = Fastify({
+    routerOptions:
+    {
+      maxParamLength: 10
+    }
+  })
+
+  fastify.get('/test/:id', (req, reply) => {
+    reply.send({ hello: 'world' })
+  })
+
+  const res = await fastify.inject({
+    method: 'GET',
+    url: '/test/123456789'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+
+  const resError = await fastify.inject({
+    method: 'GET',
+    url: '/test/123456789abcd'
+  })
+  t.assert.strictEqual(resError.statusCode, 404)
+})
+
+test('Should honor routerOptions.allowUnsafeRegex', async (t) => {
+  const fastify = Fastify({
+    routerOptions:
+    {
+      allowUnsafeRegex: true
+    }
+  })
+
+  fastify.get('/test/:id(([a-f0-9]{3},?)+)', (req, reply) => {
+    reply.send({ hello: 'world' })
+  })
+
+  let res = await fastify.inject({
+    method: 'GET',
+    url: '/test/bac,1ea'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+
+  res = await fastify.inject({
+    method: 'GET',
+    url: '/test/qwerty'
+  })
+
+  t.assert.strictEqual(res.statusCode, 404)
+})
+
+test('Should honor routerOptions.caseSensitive', async (t) => {
+  const fastify = Fastify({
+    routerOptions:
+    {
+      caseSensitive: false
+    }
+  })
+
+  fastify.get('/TeSt', (req, reply) => {
+    reply.send('test')
+  })
+
+  let res = await fastify.inject({
+    method: 'GET',
+    url: '/test'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+
+  res = await fastify.inject({
+    method: 'GET',
+    url: '/tEsT'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+
+  res = await fastify.inject({
+    method: 'GET',
+    url: '/TEST'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+})
+
+test('Should honor routerOptions.queryStringParser', async (t) => {
+  t.plan(4)
+  const fastify = Fastify({
+    routerOptions:
+    {
+      querystringParser: function (str) {
+        t.assert.ok('custom query string parser called')
+        return querystring.parse(str)
+      }
+    }
+  })
+
+  fastify.get('/test', (req, reply) => {
+    t.assert.deepStrictEqual(req.query.foo, 'bar')
+    t.assert.deepStrictEqual(req.query.baz, 'faz')
+    reply.send('test')
+  })
+
+  const res = await fastify.inject({
+    method: 'GET',
+    url: '/test?foo=bar&baz=faz'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+})
+
+test('Should honor routerOptions.useSemicolonDelimiter', async (t) => {
+  t.plan(6)
+  const fastify = Fastify({
+    routerOptions:
+    {
+      useSemicolonDelimiter: true
+    }
+  })
+
+  fastify.get('/test', (req, reply) => {
+    t.assert.deepStrictEqual(req.query.foo, 'bar')
+    t.assert.deepStrictEqual(req.query.baz, 'faz')
+    reply.send('test')
+  })
+
+  // Support semicolon delimiter
+  let res = await fastify.inject({
+    method: 'GET',
+    url: '/test;foo=bar&baz=faz'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+
+  // Support query string `?` delimiter
+  res = await fastify.inject({
+    method: 'GET',
+    url: '/test?foo=bar&baz=faz'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+})
+
+test('Should honor routerOptions.buildPrettyMeta', async (t) => {
+  t.plan(10)
+  const fastify = Fastify({
+    routerOptions:
+    {
+      buildPrettyMeta: function (route) {
+        t.assert.ok('custom buildPrettyMeta called')
+        return { metaKey: route.path }
+      }
+    }
+  })
+
+  fastify.get('/test', () => {})
+  fastify.get('/test/hello', () => {})
+  fastify.get('/testing', () => {})
+  fastify.get('/testing/:param', () => {})
+  fastify.put('/update', () => {})
+
+  await fastify.ready()
+
+  const result = fastify.printRoutes({ includeMeta: true })
+  const expected = `\
+└── /
+    ├── test (GET, HEAD)
+    │   • (metaKey) "/test"
+    │   ├── /hello (GET, HEAD)
+    │   │   • (metaKey) "/test/hello"
+    │   └── ing (GET, HEAD)
+    │       • (metaKey) "/testing"
+    │       └── /
+    │           └── :param (GET, HEAD)
+    │               • (metaKey) "/testing/:param"
+    └── update (PUT)
+        • (metaKey) "/update"
+`
+
+  t.assert.strictEqual(result, expected)
+})
+
+test('Should honor routerOptions.ignoreTrailingSlash and routerOptions.ignoreDuplicateSlashes over top level options', async t => {
+  t.plan(4)
+  const fastify = Fastify({
+    ignoreTrailingSlash: false,
+    ignoreDuplicateSlashes: false,
+    routerOptions: {
+      ignoreTrailingSlash: true,
+      ignoreDuplicateSlashes: true
+    }
+  })
+
+  fastify.get('/test//test///test', (req, res) => {
+    res.send('test')
+  })
+
+  let res = await fastify.inject('/test/test/test/')
+  t.assert.strictEqual(res.statusCode, 200)
+  t.assert.strictEqual(res.payload.toString(), 'test')
+
+  res = await fastify.inject('/test//test///test//')
+  t.assert.strictEqual(res.statusCode, 200)
+  t.assert.strictEqual(res.payload.toString(), 'test')
+})
+
+test('Should honor routerOptions.maxParamLength over maxParamLength option', async (t) => {
+  const fastify = Fastify({
+    maxParamLength: 0,
+    routerOptions:
+    {
+      maxParamLength: 10
+    }
+  })
+
+  fastify.get('/test/:id', (req, reply) => {
+    reply.send({ hello: 'world' })
+  })
+
+  const res = await fastify.inject({
+    method: 'GET',
+    url: '/test/123456789'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+
+  const resError = await fastify.inject({
+    method: 'GET',
+    url: '/test/123456789abcd'
+  })
+  t.assert.strictEqual(resError.statusCode, 404)
+})
+
+test('Should honor routerOptions.allowUnsafeRegex over allowUnsafeRegex option', async (t) => {
+  const fastify = Fastify({
+    allowUnsafeRegex: false,
+    routerOptions:
+    {
+      allowUnsafeRegex: true
+    }
+  })
+
+  fastify.get('/test/:id(([a-f0-9]{3},?)+)', (req, reply) => {
+    reply.send({ hello: 'world' })
+  })
+
+  let res = await fastify.inject({
+    method: 'GET',
+    url: '/test/bac,1ea'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+
+  res = await fastify.inject({
+    method: 'GET',
+    url: '/test/qwerty'
+  })
+
+  t.assert.strictEqual(res.statusCode, 404)
+})
+
+test('Should honor routerOptions.caseSensitive over caseSensitive option', async (t) => {
+  const fastify = Fastify({
+    caseSensitive: true,
+    routerOptions:
+    {
+      caseSensitive: false
+    }
+  })
+
+  fastify.get('/TeSt', (req, reply) => {
+    reply.send('test')
+  })
+
+  let res = await fastify.inject({
+    method: 'GET',
+    url: '/test'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+
+  res = await fastify.inject({
+    method: 'GET',
+    url: '/tEsT'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+
+  res = await fastify.inject({
+    method: 'GET',
+    url: '/TEST'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+})
+
+test('Should honor routerOptions.queryStringParser over queryStringParser option', async (t) => {
+  t.plan(4)
+  const fastify = Fastify({
+    queryStringParser: undefined,
+    routerOptions:
+    {
+      querystringParser: function (str) {
+        t.assert.ok('custom query string parser called')
+        return querystring.parse(str)
+      }
+    }
+  })
+
+  fastify.get('/test', (req, reply) => {
+    t.assert.deepStrictEqual(req.query.foo, 'bar')
+    t.assert.deepStrictEqual(req.query.baz, 'faz')
+    reply.send('test')
+  })
+
+  const res = await fastify.inject({
+    method: 'GET',
+    url: '/test?foo=bar&baz=faz'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+})
+
+test('Should honor routerOptions.useSemicolonDelimiter over useSemicolonDelimiter option', async (t) => {
+  t.plan(6)
+  const fastify = Fastify({
+    useSemicolonDelimiter: false,
+    routerOptions:
+    {
+      useSemicolonDelimiter: true
+    }
+  })
+
+  fastify.get('/test', (req, reply) => {
+    t.assert.deepStrictEqual(req.query.foo, 'bar')
+    t.assert.deepStrictEqual(req.query.baz, 'faz')
+    reply.send('test')
+  })
+
+  // Support semicolon delimiter
+  let res = await fastify.inject({
+    method: 'GET',
+    url: '/test;foo=bar&baz=faz'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+
+  // Support query string `?` delimiter
+  res = await fastify.inject({
+    method: 'GET',
+    url: '/test?foo=bar&baz=faz'
+  })
+  t.assert.strictEqual(res.statusCode, 200)
+})
diff --git a/test/use-semicolon-delimiter.test.js b/test/use-semicolon-delimiter.test.js
index efefc619e1c..5d0506d61b1 100644
--- a/test/use-semicolon-delimiter.test.js
+++ b/test/use-semicolon-delimiter.test.js
@@ -82,3 +82,87 @@ test('use semicolon delimiter set to false return 404', async (t) => {
   t.assert.ok(!result.ok)
   t.assert.strictEqual(result.status, 404)
 })
+
+test('use routerOptions semicolon delimiter default false', async t => {
+  t.plan(3)
+
+  const fastify = Fastify()
+
+  fastify.get('/1234;foo=bar', (req, reply) => {
+    reply.send(req.query)
+  })
+
+  const fastifyServer = await fastify.listen({ port: 0 })
+  t.after(() => { fastify.close() })
+
+  const result = await fetch(fastifyServer + '/1234;foo=bar')
+  t.assert.ok(result.ok)
+  t.assert.strictEqual(result.status, 200)
+  t.assert.deepStrictEqual(await result.json(), {})
+})
+
+test('use routerOptions semicolon delimiter set to true', async t => {
+  t.plan(3)
+  const fastify = Fastify({
+    routerOptions: {
+      useSemicolonDelimiter: true
+    }
+  })
+
+  fastify.get('/1234', async (req, reply) => {
+    reply.send(req.query)
+  })
+
+  const fastifyServer = await fastify.listen({ port: 0 })
+  t.after(() => { fastify.close() })
+
+  const result = await fetch(fastifyServer + '/1234;foo=bar')
+  t.assert.ok(result.ok)
+  t.assert.strictEqual(result.status, 200)
+  t.assert.deepStrictEqual(await result.json(), {
+    foo: 'bar'
+  })
+})
+
+test('use routerOptions semicolon delimiter set to false', async t => {
+  t.plan(3)
+
+  const fastify = Fastify({
+    routerOptions: {
+      useSemicolonDelimiter: false
+    }
+  })
+
+  fastify.get('/1234;foo=bar', (req, reply) => {
+    reply.send(req.query)
+  })
+
+  const fastifyServer = await fastify.listen({ port: 0 })
+  t.after(() => { fastify.close() })
+
+  const result = await fetch(fastifyServer + '/1234;foo=bar')
+  t.assert.ok(result.ok)
+  t.assert.strictEqual(result.status, 200)
+  t.assert.deepStrictEqual(await result.json(), {})
+})
+
+test('use routerOptions semicolon delimiter set to false return 404', async t => {
+  t.plan(2)
+
+  const fastify = Fastify({
+    routerOptions: {
+      useSemicolonDelimiter: false
+    }
+  })
+
+  fastify.get('/1234', (req, reply) => {
+    reply.send(req.query)
+  })
+
+  const fastifyServer = await fastify.listen({ port: 0 })
+  t.after(() => { fastify.close() })
+
+  const result = await fetch(fastifyServer + '/1234;foo=bar')
+  t.assert.ok(!result.ok)
+  t.assert.strictEqual(result.status, 404)
+})