Skip to content

Build a Recipe Search API with MongoDB and TypeScript

4 min readMongoDBDatabasesNoSQL

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:

bash
npm i -g layerbase
lbase create mongo-recipes -e mongodb --start
lbase url mongo-recipes

The last command prints the actual connection string:

text
mongodb://127.0.0.1:27017/test

The port may differ if 27017 is already in use, so copy the returned URL.

Create the TypeScript project:

bash
mkdir mongodb-recipe-search
cd mongodb-recipe-search
pnpm init
pnpm add mongodb
pnpm add -D tsx typescript @types/node

Build the search workflow

Create recipes.ts:

typescript
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:

bash
MONGODB_URL="$(lbase url mongo-recipes)" pnpm tsx recipes.ts

Expected output:

text
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.

As verified on July 23, 2026, 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:

  1. Run the application's actual query and index suite against a staging database.
  2. Compare counts and representative documents after the copy.
  3. Test every aggregation and transaction the application depends on.
  4. Keep the source MongoDB database intact until the target passes verification.
  5. 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.