Tutorial: build the blog API
Start with the Express blog example when you want the clearest possible Tango application. Express still owns the server, middleware, and route registration. Tango supplies the model contract, migrations, serializers, viewsets, and OpenAPI generation underneath.
The goal is to add a summary field to blog posts and make the posts API searchable by that summary. By the end, POST /api/posts will accept summary, post responses will return it, and the existing search and q query parameters will match it.
The feature you will add
The example already exposes a small blog API with users, posts, comments, one generic class-based endpoint, one fully custom endpoint, and an OpenAPI document generated from the same Tango resources the server uses at runtime.
When you run the server, you can call the following routes:
GET /healthGET /api/healthzGET|POST /api/generic/users- CRUD routes for
/api/users,/api/posts, and/api/comments - list filtering, search, ordering, and offset pagination on the model-backed resources
GET /api/openapi.json
The new feature extends the post resource in three ways:
- posts will accept an optional
summaryfield when they are created or updated - post list and detail responses will return
summary - post search will match
summaryas well as the existing title and content fields
That feature is a good first change because it takes you through the real application surfaces without forcing you to learn every part of the example at once.
1. Read the application in order
Before you change anything, read the example in the same order Express and Tango assemble it at runtime. That gives the later code changes some context.
Start with the server entrypoint
Open examples/blog-api/src/index.ts.
This file shows the whole application assembled in one place. Express creates the server, installs JSON parsing, and attaches concrete routes. Tango starts to matter once the file begins constructing resources and handing them to ExpressAdapter.
Read the sequence from top to bottom:
- Express creates the app and installs
express.json(). - Optional seed data is loaded when
AUTO_BOOTSTRAP=true. - The viewsets and API views are constructed.
ExpressAdapterturns those resource instances into real Express routes./api/openapi.jsonpublishes an OpenAPI document generated from the same Tango resources.
That sequence is worth holding in your head because the feature you add later will not require changes here. The server already has a posts endpoint. Your work will happen lower in the stack, where the post contract and search behavior are declared.
Read the post model
Open examples/blog-api/src/models/PostModel.ts.
In this example, Post means a blog post. The file defines that blog post in several closely related ways.
PostReadSchema describes the shape of a post once it has already been validated and loaded from persistence. PostCreateSchema describes what callers may submit when they create a post. PostUpdateSchema describes the partial-write contract for updates.
Below those schemas, PostModel adds the database-facing metadata Tango needs in order to treat the blog post as a real model. That metadata marks id as the primary key, marks authorId as a foreign key to the user model, and declares defaults for published, createdAt, and updatedAt.
The model also defines a beforeUpdate hook. That hook refreshes updatedAt whenever a post is changed through the model layer. Keeping that behavior on the model means it continues to run for API writes, scripts, and any other code that talks to PostModel.objects.
The summary field belongs here because the post model owns the stored shape of a post.
Read the post serializer
Open examples/blog-api/src/serializers/PostSerializer.ts.
This file is intentionally small. PostSerializer gathers the post model, the create schema, the update schema, and the outward-facing read schema into one resource-facing contract.
That is the reason this tutorial uses a schema change as its hands-on feature. Once the schemas are correct, the serializer already knows how to validate incoming post data and how to produce the outward response shape. You will inspect this file, but you will not need to edit it.
Read the post viewset
Open examples/blog-api/src/viewsets/PostViewSet.ts.
This class defines the HTTP surface for /api/posts. It keeps the collection routes, detail routes, and custom post-specific actions together in one place.
For the feature in this tutorial, focus on the constructor. serializer points the viewset at PostSerializer. filters declares the field filters and query aliases that the list route accepts. orderingFields declares which fields clients may use for ordering. searchFields declares which fields the generic text search will inspect.
The custom publish action sits below that constructor. It shows where resource-specific HTTP behavior belongs once the standard CRUD routes are no longer enough.
The viewset is where the new summary field becomes searchable.
Inspect the other resource styles
After the post viewset makes sense, look at the other resource classes in the example.
examples/blog-api/src/views/UserListCreateAPIView.ts shows a generic class-based resource. It is useful when a model-backed endpoint needs a narrower surface than a full viewset.
examples/blog-api/src/views/HealthAPIView.ts shows a fully custom APIView. That is the resource style to use when the endpoint is not naturally a manager-backed CRUD resource at all.
Then open examples/blog-api/src/openapi.ts. That file generates an OpenAPI document by describing the same viewsets and views the server uses at runtime. The Express server publishes the result at /api/openapi.json.
That relationship matters for the feature you are about to add. Once the post schemas and resource contract change, the generated OpenAPI document will reflect the new field as well.
2. Add the feature from a fresh machine
The steps below assume a fresh machine and a fresh clone of the Tango repository. If your machine already has Git, Node 22, and pnpm 9 available, you can start at the clone step.
Install the workspace prerequisites
Install Git from git-scm.com if it is not already available on your machine.
Install nvm using the official install script:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bashIf you use zsh and do not already have a ~/.zshrc, create it first with touch ~/.zshrc. After the install script finishes, open a new terminal or reload your shell profile so nvm is available.
Then install Node 22, enable Corepack, activate the workspace pnpm version, and clone Tango:
nvm install 22
nvm use 22
corepack enable
corepack prepare pnpm@9.13.2 --activate
git clone https://github.com/danceroutine/tango.git
cd tango
pnpm installAll remaining commands in this tutorial should be run from the repository root.
Add summary to the post schemas
Open examples/blog-api/src/models/PostModel.ts.
Start with PostReadSchema. Add summary there because post responses read from that schema. Then add the same field to PostCreateSchema so clients may submit it when they create a post.
After the edit, those two schema definitions should look like this.
export const PostReadSchema = z.object({
id: z.number(),
title: z.string().min(1),
summary: z.string().min(1).max(280).optional(),
content: z.string().min(1),
authorId: z.number(),
published: z.coerce.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const PostCreateSchema = z.object({
title: z.string().min(1),
summary: z.string().min(1).max(280).optional(),
content: z.string().min(1),
authorId: z.number(),
published: z.boolean().optional().default(false),
});PostUpdateSchema is already built from PostCreateSchema.partial(), so it will pick up the new field automatically.
The important detail is how PostModel is declared a few lines later. Its stored schema is built from PostReadSchema.extend(...). That means adding summary to PostReadSchema is also what makes summary part of the model's stored shape. Fields such as id and authorId still need explicit metadata in the extend(...) block because they carry primary-key and foreign-key behavior.
Make the new field searchable
Open examples/blog-api/src/viewsets/PostViewSet.ts.
Find the FilterSet aliases and searchFields. Add summary to the existing search surfaces so the list route can match it.
After the edit, that part of the constructor should look like this.
filters: FilterSet.define<Post>({
fields: {
authorId: true,
published: true,
},
aliases: {
q: { fields: ['title', 'summary', 'content'], lookup: 'icontains' },
created_after: { field: 'createdAt', lookup: 'gte' },
created_before: { field: 'createdAt', lookup: 'lte' },
},
}),
orderingFields: ['id', 'createdAt', 'updatedAt', 'title'],
searchFields: ['title', 'summary', 'content'],That is enough to make the new field visible to both the generic search parameter and the explicit q alias.
Leave the serializer and OpenAPI setup alone
Do not edit examples/blog-api/src/serializers/PostSerializer.ts.
The serializer already points at the post create, update, and read schemas. Once those schemas include summary, the serializer will validate and return the new field automatically.
Do not edit examples/blog-api/src/openapi.ts either.
The OpenAPI document is generated from the existing resource classes. Once the post resource reflects the new field, the generated document will reflect it too.
Generate the migration
From the repository root, generate a migration for the new column:
pnpm --filter @danceroutine/tango-example-express-blog-api run make:migrations -- --name add_post_summaryOpen the new migration file in examples/blog-api/migrations/ and confirm that it adds the new summary column to the posts table.
Apply the migration
Once the generated migration looks correct, apply it to the example database:
pnpm --filter @danceroutine/tango-example-express-blog-api setup:schemaThat command runs tango migrate with the example's tango.config.ts, so the local SQLite database moves to the new schema before you start the server.
Bootstrap the example and start the server
Now prepare the example data and start the development server:
pnpm --filter @danceroutine/tango-example-express-blog-api bootstrap
pnpm --filter @danceroutine/tango-example-express-blog-api devbootstrap seeds the example database. dev starts the Express server. On a fresh machine, that is enough to get the application into a working state once the schema has already been updated.
Existing seeded posts will not have a summary value yet, because the new field is optional and the seed script was written before you added it. That is fine. You will create a fresh post next.
Create a post that uses the new field
In a second terminal, create a post through the API:
curl -X POST http://localhost:3000/api/posts \
-H 'content-type: application/json' \
-d '{
"title": "Fresh machine walkthrough",
"summary": "A short overview used in search and list responses.",
"content": "This post exists to verify the summary field end to end.",
"authorId": 1,
"published": true
}'The JSON response should now include summary.
Verify that search and OpenAPI picked up the change
Now query the list route using text from the new summary:
curl 'http://localhost:3000/api/posts?search=overview'
curl 'http://localhost:3000/api/posts?q=overview'Both requests should return the post you just created. The first request uses the built-in search parameter. The second uses the explicit q alias declared in PostViewSet.
Finally, confirm that the generated OpenAPI document now knows about the field as well:
curl 'http://localhost:3000/api/openapi.json'Search the returned JSON for summary. You should see it appear in the post schema without any manual OpenAPI editing.
At that point you have completed the whole loop.
- change the model and schema contract
- refine the resource's public search behavior
- generate and apply a migration
- run the app
- verify the feature through the API and the generated OpenAPI document