#Quickstart
Build a small tasks API on Express in a few minutes: a first route, validated routes, a returned error, the API reference, and a test.
#1. Create the project
mkdir tasks-api && cd tasks-api
npm init -y
npm pkg set type=modulenpm install @dotpress/core @dotpress/adapter-express express zodyarn add @dotpress/core @dotpress/adapter-express express zodpnpm add @dotpress/core @dotpress/adapter-express express zodbun add @dotpress/core @dotpress/adapter-express express zoddeno add npm:@dotpress/core npm:@dotpress/adapter-express npm:express npm:zodThen add the tsconfig.json of Installation.
#2. Your first route
import { createApp, defineRoute } from '@dotpress/core'
import { expressAdapter } from '@dotpress/adapter-express'
defineRoute({
path: '/hello/:name',
handler: ({ req }) => ({ message: `Hello, ${req.params.name}!` }),
})
const app = await createApp({ adapter: expressAdapter() })
await app.listen(3000)Run it, with Node.js 22.18 or later:
node --watch src/server.tscurl localhost:3000/hello/Ada
# {"message":"Hello, Ada!"}The handler returns a value: Dotpress sends it as JSON. req.params.name is typed string, from the path.
#3. Validated routes
Move the routes to their own file, and add a group of routes for the tasks. The schemas validate each request, and type it:
import { const createRouteGroup: <const P extends string, const TProvided extends readonly unknown[] = unknown[]>(prefix: P, middlewares?: MiddlewareList<TProvided> | undefined) => RouteGroup<EmptyObject & PathParams<P>, UnionToIntersection<{ [K in keyof TProvided]: unknown extends TProvided[K] ? never : TProvided[K]; }[number]>>Creates a route group on the global router.
createRouteGroup , const defineRoute: <const P extends string, S extends RouteSchema = EmptyObject, TRawBody extends boolean = false, const TProvided extends readonly unknown[] = unknown[]>(route: RouteConfig<P, S, EmptyObject, TRawBody, TProvided, unknown>) => voidDeclares a route on the global router.
defineRoute , const notFoundError: (message?: string, data?: ErrorData) => HttpError notFoundError } from '@dotpress/core'
import { import z z } from 'zod'
defineRoute<"/hello/:name", EmptyObject, false, unknown[]>(route: RouteConfig<"/hello/:name", EmptyObject, EmptyObject, false, unknown[], unknown>): voidDeclares a route on the global router.
defineRoute ({
RouteConfig<"/hello/:name", EmptyObject, EmptyObject, false, unknown[], unknown>.path: "/hello/:name" path : '/hello/:name',
RouteConfig<"/hello/:name", EmptyObject, EmptyObject, false, unknown[], unknown>.handler: RouteHandler<EmptyObject, {
name: string;
}, false, unknown>
handler : ({ req: RouteRequest<EmptyObject, {
name: string;
}, false>
req }) => ({ message: string message : `Hello, ${req: RouteRequest<EmptyObject, {
name: string;
}, false>
req .DotpressRequest<{ name: string; }, QueryValues, unknown, UploadedFiles>.params: {
name: string;
}
params .name: string name }!` }),
})
const const Task: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>
Task = import z z .function object<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}>(shape?: {
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>
object ({ id: z.ZodUUID id : import z z .function uuid(params?: string | z.core.$ZodUUIDParams): z.ZodUUID uuid (), title: z.ZodString title : import z z .function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload) string (), done: z.ZodBoolean done : import z z .function boolean(params?: string | z.core.$ZodBooleanParams): z.ZodBoolean boolean () })
type type Task = {
id: string;
title: string;
done: boolean;
}
Task = import z z .type infer<T> = T extends {
_zod: {
output: any;
};
} ? T["_zod"]["output"] : unknown
export infer
infer <typeof const Task: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>
Task >
const const store: Map<string, {
id: string;
title: string;
done: boolean;
}>
store = new var Map: MapConstructor
new <string, {
id: string;
title: string;
done: boolean;
}>(iterable?: Iterable<readonly [string, {
id: string;
title: string;
done: boolean;
}]> | null | undefined) => Map<string, {
id: string;
title: string;
done: boolean;
}> (+3 overloads)
Map <string, type Task = {
id: string;
title: string;
done: boolean;
}
Task >()
const const tasks: RouteGroup<EmptyObject & {}, unknown> tasks = createRouteGroup<"/tasks", unknown[]>(prefix: "/tasks", middlewares?: readonly Middleware<unknown>[] | undefined): RouteGroup<EmptyObject & {}, unknown>Creates a nested group, inheriting the prefix and middlewares. Context
properties guaranteed by the group middlewares are typed in the
handlers of its routes.
createRouteGroup ('/tasks')
const tasks: RouteGroup<EmptyObject & {}, unknown> tasks .RouteGroup<EmptyObject & {}, unknown>.defineRoute<"/", {
body: z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>;
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}, false, unknown[]>(route: RouteConfig<"/", {
body: z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>;
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}, EmptyObject & {}, false, unknown[], unknown>): void
defineRoute ({
RouteConfig<P extends string, S extends RouteSchema, TParentParams = EmptyObject, TRawBody extends boolean = false, TProvided extends readonly unknown[] = unknown[], TGroupContext = unknown>.method?: HttpMethod | undefinedDefaults to get.
method : 'post',
RouteConfig<"/", { body: ZodObject<{ title: ZodString; }, $strip>; response: ZodObject<{ id: ZodUUID; title: ZodString; done: ZodBoolean; }, $strip>; }, EmptyObject & {}, false, unknown[], unknown>.path: "/" path : '/',
RouteConfig<P extends string, S extends RouteSchema, TParentParams = EmptyObject, TRawBody extends boolean = false, TProvided extends readonly unknown[] = unknown[], TGroupContext = unknown>.status?: number | undefinedSuccess status. Defaults to 200 (204 when nothing is returned).
status : 201,
RouteConfig<"/", { body: ZodObject<{ title: ZodString; }, $strip>; response: ZodObject<{ id: ZodUUID; title: ZodString; done: ZodBoolean; }, $strip>; }, EmptyObject & {}, false, unknown[], unknown>.schema?: SchemaDefinition<{
body: z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>;
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}> | undefined
schema : (z: typeof import("zod") z ) => ({
body: z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>
body : z: typeof import("zod") z .function object<{
title: z.ZodString;
}>(shape?: {
title: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>
object ({ title: z.ZodString title : z: typeof import("zod") z .function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload) string ()._ZodString<$ZodStringInternals<string>>.trim(): z.ZodString trim ()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodString min (1) }),
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>
response : const Task: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>
Task ,
}),
RouteConfig<"/", { body: ZodObject<{ title: ZodString; }, $strip>; response: ZodObject<{ id: ZodUUID; title: ZodString; done: ZodBoolean; }, $strip>; }, EmptyObject & {}, false, unknown[], unknown>.handler: RouteHandler<{
body: z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>;
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}, {}, false, unknown>
handler : ({ req: RouteRequest<{
body: z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>;
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}, {}, false>
req }) => {
const const task: {
id: `${string}-${string}-${string}-${string}-${string}`;
title: string;
done: boolean;
}
task = { id: `${string}-${string}-${string}-${string}-${string}` id : var crypto: Crypto crypto .Crypto.randomUUID(): `${string}-${string}-${string}-${string}-${string}`The randomUUID() method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator.
Available only in secure contexts.
randomUUID (), title: string title : req: RouteRequest<{
body: z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>;
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}, {}, false>
req .DotpressRequest<{}, QueryValues, { title: string; }, UploadedFiles>.body: {
title: string;
}
body .title: string title , done: boolean done : false }
const store: Map<string, {
id: string;
title: string;
done: boolean;
}>
store .Map<string, { id: string; title: string; done: boolean; }>.set(key: string, value: {
id: string;
title: string;
done: boolean;
}): Map<string, {
id: string;
title: string;
done: boolean;
}>
Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated.
set (const task: {
id: `${string}-${string}-${string}-${string}-${string}`;
title: string;
done: boolean;
}
task .id: `${string}-${string}-${string}-${string}-${string}` id , const task: {
id: `${string}-${string}-${string}-${string}-${string}`;
title: string;
done: boolean;
}
task )
return const task: {
id: `${string}-${string}-${string}-${string}-${string}`;
title: string;
done: boolean;
}
task
},
})
const tasks: RouteGroup<EmptyObject & {}, unknown> tasks .RouteGroup<EmptyObject & {}, unknown>.defineRoute<"/:id", {
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}, false, unknown[]>(route: RouteConfig<"/:id", {
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}, EmptyObject & {}, false, unknown[], unknown>): void
defineRoute ({
RouteConfig<"/:id", { response: ZodObject<{ id: ZodUUID; title: ZodString; done: ZodBoolean; }, $strip>; }, EmptyObject & {}, false, unknown[], unknown>.path: "/:id" path : '/:id',
RouteConfig<"/:id", { response: ZodObject<{ id: ZodUUID; title: ZodString; done: ZodBoolean; }, $strip>; }, EmptyObject & {}, false, unknown[], unknown>.schema?: SchemaDefinition<{
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}> | undefined
schema : () => ({ response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>
response : const Task: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>
Task }),
RouteConfig<"/:id", { response: ZodObject<{ id: ZodUUID; title: ZodString; done: ZodBoolean; }, $strip>; }, EmptyObject & {}, false, unknown[], unknown>.handler: RouteHandler<{
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}, {
id: string;
}, false, unknown>
handler : ({ req: RouteRequest<{
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}, {
id: string;
}, false>
req }) => const store: Map<string, {
id: string;
title: string;
done: boolean;
}>
store .Map<string, { id: string; title: string; done: boolean; }>.get(key: string): {
id: string;
title: string;
done: boolean;
} | undefined
Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.
@returnsReturns the element associated with the specified key. If no element is associated with the specified key, undefined is returned. get (req: RouteRequest<{
response: z.ZodObject<{
id: z.ZodUUID;
title: z.ZodString;
done: z.ZodBoolean;
}, z.core.$strip>;
}, {
id: string;
}, false>
req .DotpressRequest<{ id: string; }, QueryValues, unknown, UploadedFiles>.params: {
id: string;
}
params .id: string id ) ?? function notFoundError(message?: string, data?: ErrorData): HttpError notFoundError ('Task not found'),
})Hover req.body and req.params. Then import the routes in src/server.ts, before creating the app:
import { createApp } from '@dotpress/core'
import { expressAdapter } from '@dotpress/adapter-express'
import './routes.ts'
const app = await createApp({ adapter: expressAdapter() })
await app.listen(3000)An invalid body never reaches the handler:
curl -X POST localhost:3000/tasks -H 'content-type: application/json' -d '{"title":""}'{
"status": 400,
"error": "VALIDATION_FAILED",
"message": "Validation failed",
"data": {
"issues": [
{
"source": "body",
"path": ["title"],
"code": "too_small",
"message": "Too small: expected string to have >=1 characters"
}
]
}
}And an unknown task gets the error returned by the handler:
curl localhost:3000/tasks/unknown
# {"status":404,"error":"NOT_FOUND","message":"Task not found"}#4. The API reference
npm install @dotpress/openapiyarn add @dotpress/openapipnpm add @dotpress/openapibun add @dotpress/openapideno add npm:@dotpress/openapiThe OpenAPI plugin builds the document from your routes and schemas. Create the app in its own file, so that the tests can create it too:
import { createApp, type HttpAdapter } from '@dotpress/core'
import { openapiPlugin } from '@dotpress/openapi'
import './routes.ts'
export const createTasksApp = <TAdapter extends HttpAdapter>(adapter: TAdapter) =>
createApp({
adapter,
plugins: [openapiPlugin({ info: { title: 'Tasks API', version: '1.0.0' } })],
})import { expressAdapter } from '@dotpress/adapter-express'
import { createTasksApp } from './app.ts'
const app = await createTasksApp(expressAdapter())
await app.listen(3000)Open localhost:3000/docs: every route is documented, with its parameters, body and responses. The document itself is served at /openapi.json.
#5. A test
npm install -D vitest @dotpress/testingyarn add -D vitest @dotpress/testingpnpm add -D vitest @dotpress/testingbun add -D vitest @dotpress/testingdeno add -D npm:vitest npm:@dotpress/testingThe memory adapter runs the requests in memory, without opening a port: the test uses the app as configured in production.
import { memoryAdapter } from '@dotpress/testing'
import { expect, it } from 'vitest'
import { createTasksApp } from '../src/app.ts'
it('creates a task', async () => {
const adapter = memoryAdapter()
await createTasksApp(adapter)
const res = await adapter.fetch(
new Request('http://localhost/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'Write a test' }),
})
)
expect(res.status).toBe(201)
expect(await res.json()).toMatchObject({ title: 'Write a test', done: false })
})npx vitest run#Next steps
- Understand how Dotpress works, then the request pipeline.
- Organize a growing app: Project structure.
- Go further with routes: Route groups, Validation, Responses, Middlewares.
- See a complete project: the Express example.