Build a Recipe Search API with MongoDB and TypeScript
Short version: npm i -g layerbase then lbase create mongo-recipes -e mongodb --start runs native MongoDB on your machine, with no Docker and no account. Layerbase Desktop does the same thing with a button. Layerbase Cloud does not host the MongoDB server itself: the MongoDB-compatible cloud option provisions FerretDB, which speaks the MongoDB wire protocol on top of PostgreSQL and needs the Solo plan.
A recipe catalog looks simple until every item has a different ingredient list, tags, preparation time, and search criteria. MongoDB can store that nested shape directly, but a flexible document model does not remove the need for validation, indexes, or deliberate migrations.
This tutorial builds one useful path: load five recipes, validate their shape, and search by cuisine, total time, and a nested ingredient.
Who this is for: TypeScript developers deciding whether a document database fits a content or catalog workload.
Outcome: One runnable script with validated documents, repeatable seed data, a compound index, a nested search, and a cuisine summary.
Time: About 20 minutes, plus the first MongoDB binary download.
Prerequisites: Node.js 20 or newer and pnpm. No Docker or Cloud account is required.
Start native MongoDB locally
Install the Layerbase CLI, then start MongoDB:
npm i -g layerbase
lbase create mongo-recipes -e mongodb --start
lbase url mongo-recipesThe last command prints the actual connection string:
mongodb://127.0.0.1:27017/testThe port may differ if 27017 is already in use, so copy the returned URL.
Create the TypeScript project:
mkdir mongodb-recipe-search
cd mongodb-recipe-search
pnpm init
pnpm add mongodb
pnpm add -D tsx typescript @types/nodeBuild the search workflow
Create recipes.ts:
import { MongoClient } from 'mongodb'
type Ingredient = {
name: string
amount: string
}
type Recipe = {
tutorialRun: string
title: string
cuisine: string
prepTimeMinutes: number
cookTimeMinutes: number
totalTimeMinutes: number
rating: number
ingredients: Ingredient[]
tags: string[]
}
const TUTORIAL_RUN = 'layerbase-recipe-search-v1'
const uri = process.env.MONGODB_URL ?? 'mongodb://127.0.0.1:27017'
const client = new MongoClient(uri)
const seedRecipes = [
{
title: 'Guacamole',
cuisine: 'Mexican',
prepTimeMinutes: 10,
cookTimeMinutes: 0,
rating: 4.6,
ingredients: [
{ name: 'avocado', amount: '3' },
{ name: 'lime', amount: '1' },
],
tags: ['vegetarian', 'quick', 'no-cook'],
},
{
title: 'Miso Soup',
cuisine: 'Japanese',
prepTimeMinutes: 5,
cookTimeMinutes: 10,
rating: 4.5,
ingredients: [
{ name: 'miso paste', amount: '3 tbsp' },
{ name: 'tofu', amount: '150 g' },
],
tags: ['vegetarian', 'quick'],
},
{
title: 'Bruschetta',
cuisine: 'Italian',
prepTimeMinutes: 10,
cookTimeMinutes: 10,
rating: 4.4,
ingredients: [
{ name: 'tomato', amount: '4' },
{ name: 'garlic', amount: '2 cloves' },
],
tags: ['vegetarian', 'quick', 'appetizer'],
},
{
title: 'Pasta Carbonara',
cuisine: 'Italian',
prepTimeMinutes: 10,
cookTimeMinutes: 15,
rating: 4.9,
ingredients: [
{ name: 'spaghetti', amount: '400 g' },
{ name: 'egg', amount: '4' },
],
tags: ['quick', 'comfort-food'],
},
{
title: 'Palak Paneer',
cuisine: 'Indian',
prepTimeMinutes: 15,
cookTimeMinutes: 30,
rating: 4.7,
ingredients: [
{ name: 'spinach', amount: '500 g' },
{ name: 'paneer', amount: '250 g' },
],
tags: ['vegetarian', 'comfort-food'],
},
]
await client.connect()
try {
const db = client.db('recipe_tutorial')
const collectionExists = await db
.listCollections({ name: 'recipes' })
.hasNext()
if (!collectionExists) {
await db.createCollection<Recipe>('recipes', {
validator: {
$jsonSchema: {
bsonType: 'object',
required: [
'tutorialRun',
'title',
'cuisine',
'totalTimeMinutes',
'rating',
'ingredients',
],
properties: {
title: { bsonType: 'string' },
cuisine: { bsonType: 'string' },
totalTimeMinutes: { bsonType: 'int', minimum: 0 },
rating: { bsonType: ['double', 'int'], minimum: 0, maximum: 5 },
ingredients: { bsonType: 'array', minItems: 1 },
},
},
},
})
}
const recipes = db.collection<Recipe>('recipes')
// Remove only this tutorial's previous seed data.
await recipes.deleteMany({ tutorialRun: TUTORIAL_RUN })
const documents: Recipe[] = seedRecipes.map((recipe) => ({
...recipe,
tutorialRun: TUTORIAL_RUN,
totalTimeMinutes:
recipe.prepTimeMinutes + recipe.cookTimeMinutes,
}))
await recipes.insertMany(documents)
await recipes.createIndex(
{
tutorialRun: 1,
cuisine: 1,
totalTimeMinutes: 1,
'ingredients.name': 1,
},
{ name: 'recipe_search' },
)
console.log(`Loaded ${documents.length} validated recipes`)
const quickItalianWithTomato = await recipes
.find(
{
tutorialRun: TUTORIAL_RUN,
cuisine: 'Italian',
totalTimeMinutes: { $lte: 20 },
'ingredients.name': 'tomato',
},
{
projection: {
_id: 0,
title: 1,
totalTimeMinutes: 1,
rating: 1,
},
},
)
.sort({ rating: -1, title: 1 })
.toArray()
console.log('\nItalian recipes with tomato, ready in 20 minutes:')
for (const recipe of quickItalianWithTomato) {
console.log(
` ${recipe.title}: ${recipe.totalTimeMinutes} min, ${recipe.rating} stars`,
)
}
const cuisineSummary = await recipes
.aggregate<{ cuisine: string; averageRating: number; recipes: number }>([
{ $match: { tutorialRun: TUTORIAL_RUN } },
{
$group: {
_id: '$cuisine',
averageRating: { $avg: '$rating' },
recipes: { $sum: 1 },
},
},
{
$project: {
_id: 0,
cuisine: '$_id',
averageRating: 1,
recipes: 1,
},
},
{ $sort: { averageRating: -1, cuisine: 1 } },
])
.toArray()
console.log('\nAverage rating by cuisine:')
for (const row of cuisineSummary) {
console.log(
` ${row.cuisine}: ${row.averageRating.toFixed(2)} (${row.recipes})`,
)
}
} finally {
await client.close()
}Run it with the connection string returned by the CLI:
MONGODB_URL="$(lbase url mongo-recipes)" pnpm tsx recipes.tsExpected output:
Loaded 5 validated recipes
Italian recipes with tomato, ready in 20 minutes:
Bruschetta: 20 min, 4.4 stars
Average rating by cuisine:
Indian: 4.70 (1)
Italian: 4.65 (2)
Mexican: 4.60 (1)
Japanese: 4.50 (1)The query uses a stored totalTimeMinutes value rather than calculating preparation plus cooking time inside every request. That value can be indexed. If either source field changes, update the total in the same write or calculate it in a controlled application layer.
The script also uses a tutorialRun marker. Rerunning it deletes only its own five records instead of dropping a database or collection that might contain unrelated work.
Flexible does not mean unmanaged
MongoDB lets documents in one collection have different shapes. That can be useful during product development, but an established application still needs rules.
This example creates a collection validator that rejects missing or incorrectly typed fields. Existing collections can gain or change schema validation rules through a planned migration. Index changes, field renames, and data backfills are migrations too, even when there is no ALTER TABLE.
The compound index matches this tutorial's query shape. Do not add indexes for every possible field. Each index consumes storage and makes writes more expensive. Use production query plans and actual access patterns to decide what deserves an index.
MongoDB writes are atomic at the single-document level, including updates to several fields in one operation. That does not mean concurrent writers can never overwrite each other. Use update filters that include the expected current value, version fields, or transactions when the business rule requires stronger coordination.
What Layerbase Cloud runs
The local command above runs native MongoDB. Layerbase Desktop can also run native MongoDB on macOS, Windows, and Linux.
Layerbase Cloud does not host the MongoDB server. Selecting the MongoDB-compatible Cloud option provisions FerretDB, an Apache-licensed database that speaks the MongoDB wire protocol and stores documents through PostgreSQL.
FerretDB supports many common MongoDB drivers, CRUD operations, indexes, and aggregation patterns, but it is not complete MongoDB or an Atlas replacement. Features such as change streams, Atlas Search, and some BSON or aggregation behavior require a different design or native MongoDB.
FerretDB requires the $5 per month Solo plan. Copy the generated TLS connection string from Quick Connect rather than constructing it yourself. Check current pricing before purchasing because plan details can change.
Before moving this tutorial or a real application to FerretDB:
- Run the application's actual query and index suite against a staging database.
- Compare counts and representative documents after the copy.
- Test every aggregation and transaction the application depends on.
- Keep the source MongoDB database intact until the target passes verification.
- Plan how writes made after cutover would be copied back if rollback is needed.
The MongoDB versus FerretDB comparison covers the architectural differences. The MongoDB to FerretDB migration guide covers copying data, verification, cutover, and rollback.
When MongoDB is the wrong fit
Native MongoDB is a reasonable choice when application objects naturally contain bounded nested data and the team understands the query patterns. It is usually the wrong default when:
- Relational joins and cross-record constraints dominate the model.
- Reports need arbitrary combinations of dimensions.
- Documents grow without a clear size or ownership boundary.
- The application depends on Atlas-only services but portability is a requirement.
- The only reason for choosing it is avoiding schema design.
Prove the model locally first. If the workload stays within FerretDB's compatibility boundary, Layerbase Cloud provides a managed production path. If it needs native MongoDB behavior, keep native MongoDB and choose hosting that supports those requirements directly.
FAQ
Can I create a MongoDB database on Layerbase Cloud?
Not the MongoDB server itself. Its license keeps it off our managed platform, so the MongoDB-compatible option on the create page provisions FerretDB instead, which requires the Solo plan. Native MongoDB is a local engine here: run it with the Layerbase CLI or Layerbase Desktop.
Does running MongoDB locally need Docker?
No. lbase create mongo-recipes -e mongodb --start downloads the MongoDB binary for your platform and runs it directly. Node.js 20 or newer and pnpm are the only other prerequisites for this tutorial.
Will my MongoDB code work against FerretDB?
Common CRUD, indexes, and aggregation patterns do, through the same official driver. Change streams, Atlas Search, and some BSON and aggregation behavior do not. Run your real query and index suite against a staging database and compare counts and representative documents before you commit to a cutover.
Does a flexible document model mean I can skip migrations?
No. Adding or changing schema validation rules, renaming fields, backfilling data, and creating indexes are all migrations, even without an ALTER TABLE to write. Plan them the same way you would in a relational database.
Keep reading
- CouchDB alternatives in 2026: pick by replication modelCompare CouchDB alternatives for offline-first sync, document storage, managed hosting, and relational data. The right replacement depends on which part of CouchDB you actually use.
- Northflank alternatives: a preview environment is not a database branchNorthflank gives every pull request its own stack, and the database in that stack starts empty unless you seed it or restore it from a backup you already had. Here is exactly how their forks work, what a copy-on-write branch does differently, what each one costs while a PR sits open, and the cases where Northflank is the right answer.
- MongoDB Atlas alternatives: flat pricing and a document database you can branchWhat you actually get when you leave MongoDB Atlas for managed FerretDB on Layerbase: the same wire protocol and driver, a flat monthly price instead of a per-tier meter, branching Atlas does not offer, and an honest list of the cases where you should stay put.
- MongoDB vs FerretDB on the same hardware: the benchmark, and what we had to fix to run it fairlyYCSB against MongoDB 8.0 and FerretDB 2.7 on the exact hardware behind our $65/mo dedicated preset. Once our own tuning was right, FerretDB held 3-4x MongoDB throughput on the mixed workload and about 5x on reads. The corrected numbers, what had to be fixed first, and what we are not claiming.