defineRoute
Describe the feature
In CLI and codebase scanning architectures, type inference and code merging often create inconsistencies with methods like .get, .post, etc., leading to a different set of challenges in every project. To prevent this and achieve a strictly consistent system, introducing a new structured approach unlocks many bottlenecks. In the modern ecosystem, support for StandardSchema significantly simplifies module development—even in specialized modules like OpenAPI and others.
import { H3, defineEventHandler, defineRoute, readBody, readValidatedBody } from "h3";
import { z } from "zod";
export const book = defineRoute({
method: "GET",
route: "/api/books",
routerParams: z.object({
id: z.string(),
}),
queryParams: z.object({
page: z.string().optional(),
limit: z.string().optional(),
}),
input: z.object({
title: z.string(),
}),
output: z.object({
message: z.string(),
name: z.string(),
}),
handler: async (event)=> {
const data = await readBody(event);
const data2 = await readValidatedBody(event)
return {
message: "Book list",
name: ''
}
}
});
const app = new H3();
app.addRoute(book);
Additional information
- [x] Would you be willing to help implement this feature?
This looks familiar from libraries like https://github.com/ts-rest/ts-rest and https://github.com/unnoq/orpc I think it would be great to make this compatible with these too.
I'd personally like if there were:
headerssupport withRecord<string, StandardSchemaV1>- output would be a
Record<StatusCode, StandardSchemaV1>so with asomeResponseHelper(<statusCode>, <my-response>)we could validate the output types before returning them from the handler (and the handler would by default allow a union of these to be returned) - the
routewould be also defineable without a handler that can be later "implemented" with one, so clients (in browser) can also use the defined contracts without any server code leaking into that
import { H3, defineEventHandler, defineRoute, readBody, readValidatedBody } from "h3";
import { z } from "zod";
export const book = defineRoute({
method: "GET",
route: "/api/books",
routerParams: z.object({
id: z.string(),
}),
queryParams: z.object({
page: z.string().optional(),
limit: z.string().optional(),
}),
headers: {
"x-custom-header": z.string().optional(),
},
input: z.object({
title: z.string(),
}),
output: {
200: z.object({
message: z.string(),
name: z.string(),
}),
204: z.void(),
404: z.object({
message: z.string(),
statusCode: z.literal(404),
})
},
handler: async (event) => {
const data = await readBody(event);
const data2 = await readValidatedBody(event);
return {
message: "Book list",
name: "",
};
},
});
const app = new H3();
app.addRoute(book);
defineRoute looks very clean way to declare routes and register later, especially for composition in an H3-only project.
My main concern would be that we will eventually put too much opinion into defineRoute util, which makes it less flexible or extendable. Universal (client) route definitions are an interesting example @zsilbi mentioned. Also, in Nitro, we have a separate defineRouteMeta that is statically extractable.
I am thinking we could take a more generic approach to this, unblocking h3 core first.
app.register(spec)
Right now, we do have several methods app.use (for middleware), app.[method]/app.all/app.on for routes, and app.mount for fetch-compatible handlers.
While they are ergonomic to register things in place (one file), they are not declarative enough to compose from multiple places, which makes a proposal like app.addRoute(defineRoute()) immediately sensible.
From H3 core itself, we could support a declarative object register method. And this object could be made with different abstractions, an addRoute that only specifies route/method or different ways of validation, or RPC, etc.
It could start as simple as something like this:
app.register({ route: { method, route, handler } })
Then a defineRoute util, either from h3 core, an external/opininated library or nitro, can generate this object (app.register(defineRoute({}))), or even multiple versions of it, for example nitro can make one without handler but only meta for open API and on with route, method and validators for client-side.
I have ended up adding a more generic plugin interface in #1098
Imaginary implementation example for app.register(route) using experimental defineValidatedHandler (#1097)
// Plugin
const defineRoute = (routeObj) => definePlugin((app) => {
app.on(routeObj.method || "", routeObj.route, defineValidatedHandler(routeObj))
})
// Define routes
const book = defineRoute({ method: "get", route: "/book", handler: () => "Hello World!" })
// Register Routes (as plugin)
const app = new H3().register(book())
actually my plugins dream was like this.
import { H3, defineEventHandler, defineRoute, readBody, readValidatedBody } from "h3";
import { z } from "zod";
export const book = defineRoute({
method: "GET",
route: "/api/books",
plugins: [
(event)=> {},
],
......
- middleware can hook to request (
event) - plugins can hook to h3 instance (
app)- and they can add middleware too!
In your example this works as intended:
export const book = defineRoute({
method: "GET",
route: "/api/books",
middleware: [
(event)=> {},
],
I think a name change is necessary.
middleware -> plugin
export const book = defineRoute({
method: "GET",
route: "/api/books",
plugin: [
(event)=> {},
],
and Plugin -> Module
// Module
const defineRoute = (routeObj) => defineModule((app) => {
app.on(routeObj.method || "", routeObj.route, defineValidatedHandler(routeObj))
})
Middleware is already a well-known term for request interception, we cannot just change it.
Plugin is also already a well-known term for extending libraries (similar to vue, etc)
I get you might be coming from Nitro (and Nuxt) context about term "Module". We had to introduce that term because Nuxt already had the concept of "Plugins" for runtime extensions (similar to here).
Apart from disussing about names, i think last part of this isue to be done is to export small defineRoute utility like you proposed.
I think defineRoute should definitely come out of here so that we can record steady progress in all kinds of projects.
I agree! (releasing beta.0 shortly) we can finish work on defineRoute together once you made PR for beta.1
I've been thinking about this defineRoute idea and wanted to throw out another approach that might be pretty neat - what if we could define multiple HTTP methods for the same path all in one go?
const userCRUDRoute = defineRoute({
path: '/users/:id?',
schemas: {
params: z.object({
id: z.string().uuid().optional()
})
},
methods: {
GET: {
schema: {
response: z.array(UserSchema).or(UserSchema)
},
handler: async (event) => {
const { id } = event.context.params
return id ? await getUserById(id) : await getAllUsers()
}
},
POST: {
schema: {
body: CreateUserSchema,
response: UserSchema
},
handler: async (event) => {
const body = await readBody(event)
return await createUser(body)
}
},
PUT: {
schema: {
body: UpdateUserSchema,
response: UserSchema
},
handler: async (event) => {
const { id } = event.context.params
const body = await readBody(event)
return await updateUser(id, body)
}
}
}
})
This feels like it could fix those type inference headaches and make the code way more organized. Thoughts? Does this sound like something that could work, or am I way off base here? 🤔
@OskarLebuda I have tested it and it is impossible to separate it on the cli side and to create tools like OpenAPI, or to combine path + methods in the object and type safety.
If we take advantage of the object's first-order merging power, everything is fast and error-free.
@OskarLebuda I have tested it and it is impossible to separate it on the cli side and to create tools like OpenAPI, or to combine path + methods in the object and type safety.
I see - thnx 🙏🏻
Thanks for sharing the idea @OskarLebuda, I was actually thinking the same.
@productdevbook I get that it makes the most sense for generated routes to be separated. Also, for performance reasons (lazy loading), we split routes with different methods in Nitro like this.
But respecting all different usecases (sometimes it is much more convenient to define methods in same export), let's support BOTH (why not!).
Either a handler for all or additional methods for method-specific handlers.
fetchdts also supports both when later we want to work on type inference.
Yes, we are open to all ideas, always share. The only thing that needs to be considered is how to merge the same path, method etc structures from different files, npm packages. We should think of interface merge events when you try to merge one url with another url. Because for one url there will be defineRoute coming from different modules and npm packages. and we will want to merge them in the same place. There are cases where they merge into the same url methods.
there is also this situation, if we allow everything, then when we write a CLI service for all users using h3, it will be complicated.
defineRoute internally calls this.on which uses rou3. It makes a tree of all possible routes and matching methods for complicated part of merging.
In your CLI framework, you can limit options when it makes best sense. that's power of layers.
@pi0 You're right, I shouldn't divide opinions. They should test it and maybe they'll find a better way. @OskarLebuda We can test your idea and if you are sure, we can decide here to support it and work for the future.
@pi0 You're right, I shouldn't divide opinions. They should test it and maybe they'll find a better way. @OskarLebuda We can test your idea and if you are sure, we can decide here to support it and work for the future.
If you need any help - let me know 🙌🏻
@productdevbook Can you make a PR to add a minimal version of defineRoute / defineWebSocketRoute? (it can be added to src/utils/route.ts
I created a pr. Let's check it. https://github.com/h3js/h3/pull/1143 :D defineWebSocketRoute I will send it later as a separate pr
I created a pr. Let's check it. defineWebSocketRoute - https://github.com/h3js/h3/pull/1149
I think it would be helpful to reference how the hono framework approaches this problem This feature set is crucial for me. I would reference this implementation as we figure out the best way to do it.
One thing to note is the integration between Zod Validation, Schema definition, and Route definition. This way validation and schema only has to be defined once.