Getting Started with MongoDB

MongoDBDatabasesNoSQL

MongoDB is the most popular document database. Instead of tables with fixed schemas, it stores flexible JSON-like documents (BSON) in collections. Documents in the same collection can have completely different fields. This makes MongoDB natural for data that doesn't fit neatly into rows and columns: user profiles with varying attributes, product catalogs with different specs per category, event logs with different payloads. You model data the way your application uses it, not the way a schema demands.

We'll build a recipe collection app in one TypeScript file, covering queries, aggregations, text search, and update operations. Run it against a local instance or use Layerbase Cloud for a managed MongoDB that's ready in seconds.

Contents

Create a MongoDB Instance

Local with SpinDB

SpinDB gets MongoDB running locally in about 30 seconds. It downloads the binary, configures it, and starts the server. No Docker, no manual setup. (What is SpinDB?)

Install SpinDB globally:

bash
npm i -g spindb    # npm
pnpm add -g spindb # pnpm

Or run it directly without installing:

bash
npx spindb create mongo1 -e mongodb --start  # npm
pnpx spindb create mongo1 -e mongodb --start # pnpm

If you installed globally, create and start a MongoDB instance:

bash
spindb create mongo1 -e mongodb --start

SpinDB downloads the MongoDB binary, configures it, and starts the server. Verify it's running:

bash
spindb url mongo1
text
mongodb://127.0.0.1:27017

Leave the server running.

Layerbase Cloud

Layerbase Cloud hosts managed MongoDB instances. Create one and grab the connection URL from the Quick Connect panel.

Cloud instances use TLS, so the connection string looks like this:

typescript
const client = new MongoClient(
  'mongodb://user:pass@cloud.layerbase.dev:PORT/db?tls=true',
)

Everything else in this guide works identically whether you're running locally or on Layerbase Cloud. Just swap in your connection details.

Set Up the Project

bash
mkdir mongodb-recipes && cd mongodb-recipes
pnpm init
pnpm add mongodb
pnpm add -D tsx typescript

Create a file called recipes.ts. All the code in this post goes into that one file.

The mongodb package is the official driver for Node.js, giving you full access to MongoDB's query language, aggregation framework, and administration commands.

The Recipe Dataset

Twelve recipes. The dataset uses nested arrays of objects, varying structures, and mixed types to show the kind of flexible documents that make a document database worth using:

typescript
const recipes = [
  {
    title: 'Margherita Pizza',
    cuisine: 'Italian',
    servings: 4,
    prepTimeMinutes: 20,
    cookTimeMinutes: 15,
    difficulty: 'easy',
    ingredients: [
      { name: 'pizza dough', amount: 1, unit: 'ball' },
      { name: 'tomato sauce', amount: 0.5, unit: 'cup' },
      { name: 'mozzarella', amount: 200, unit: 'g' },
      { name: 'basil', amount: 10, unit: 'leaves' },
    ],
    tags: ['vegetarian', 'quick', 'comfort-food'],
    rating: 4.8,
  },
  {
    title: 'Chicken Tikka Masala',
    cuisine: 'Indian',
    servings: 4,
    prepTimeMinutes: 30,
    cookTimeMinutes: 40,
    difficulty: 'medium',
    ingredients: [
      { name: 'chicken breast', amount: 500, unit: 'g' },
      { name: 'yogurt', amount: 1, unit: 'cup' },
      { name: 'tomato sauce', amount: 2, unit: 'cups' },
      { name: 'garam masala', amount: 2, unit: 'tbsp' },
      { name: 'garlic', amount: 4, unit: 'cloves' },
      { name: 'ginger', amount: 1, unit: 'tbsp' },
    ],
    tags: ['spicy', 'comfort-food', 'protein-rich'],
    rating: 4.7,
  },
  {
    title: 'Sushi Rolls',
    cuisine: 'Japanese',
    servings: 2,
    prepTimeMinutes: 45,
    cookTimeMinutes: 20,
    difficulty: 'hard',
    ingredients: [
      { name: 'sushi rice', amount: 2, unit: 'cups' },
      { name: 'nori sheets', amount: 4, unit: 'sheets' },
      { name: 'salmon', amount: 200, unit: 'g' },
      { name: 'avocado', amount: 1, unit: 'whole' },
      { name: 'rice vinegar', amount: 3, unit: 'tbsp' },
    ],
    tags: ['seafood', 'healthy', 'date-night'],
    rating: 4.5,
  },
  {
    title: 'Guacamole',
    cuisine: 'Mexican',
    servings: 6,
    prepTimeMinutes: 10,
    cookTimeMinutes: 0,
    difficulty: 'easy',
    ingredients: [
      { name: 'avocado', amount: 3, unit: 'whole' },
      { name: 'lime', amount: 2, unit: 'whole' },
      { name: 'cilantro', amount: 0.25, unit: 'cup' },
      { name: 'onion', amount: 0.5, unit: 'whole' },
      { name: 'garlic', amount: 2, unit: 'cloves' },
    ],
    tags: ['vegetarian', 'quick', 'party', 'no-cook'],
    rating: 4.6,
  },
  {
    title: 'Pasta Carbonara',
    cuisine: 'Italian',
    servings: 2,
    prepTimeMinutes: 10,
    cookTimeMinutes: 15,
    difficulty: 'medium',
    ingredients: [
      { name: 'spaghetti', amount: 200, unit: 'g' },
      { name: 'pancetta', amount: 150, unit: 'g' },
      { name: 'eggs', amount: 3, unit: 'whole' },
      { name: 'parmesan', amount: 100, unit: 'g' },
      { name: 'garlic', amount: 2, unit: 'cloves' },
    ],
    tags: ['quick', 'comfort-food', 'protein-rich'],
    rating: 4.9,
  },
  {
    title: 'Miso Soup',
    cuisine: 'Japanese',
    servings: 4,
    prepTimeMinutes: 5,
    cookTimeMinutes: 10,
    difficulty: 'easy',
    ingredients: [
      { name: 'miso paste', amount: 3, unit: 'tbsp' },
      { name: 'tofu', amount: 200, unit: 'g' },
      { name: 'dashi stock', amount: 4, unit: 'cups' },
      { name: 'green onion', amount: 2, unit: 'stalks' },
    ],
    tags: ['vegetarian', 'quick', 'healthy', 'comfort-food'],
    rating: 4.3,
  },
  {
    title: 'Tacos al Pastor',
    cuisine: 'Mexican',
    servings: 4,
    prepTimeMinutes: 40,
    cookTimeMinutes: 25,
    difficulty: 'medium',
    ingredients: [
      { name: 'pork shoulder', amount: 500, unit: 'g' },
      { name: 'pineapple', amount: 1, unit: 'cup' },
      { name: 'corn tortillas', amount: 12, unit: 'whole' },
      { name: 'onion', amount: 1, unit: 'whole' },
      { name: 'cilantro', amount: 0.5, unit: 'cup' },
      { name: 'garlic', amount: 3, unit: 'cloves' },
    ],
    tags: ['spicy', 'street-food', 'protein-rich'],
    rating: 4.8,
  },
  {
    title: 'Palak Paneer',
    cuisine: 'Indian',
    servings: 3,
    prepTimeMinutes: 15,
    cookTimeMinutes: 25,
    difficulty: 'medium',
    ingredients: [
      { name: 'spinach', amount: 500, unit: 'g' },
      { name: 'paneer', amount: 250, unit: 'g' },
      { name: 'onion', amount: 1, unit: 'whole' },
      { name: 'garlic', amount: 3, unit: 'cloves' },
      { name: 'ginger', amount: 1, unit: 'tbsp' },
      { name: 'cream', amount: 2, unit: 'tbsp' },
    ],
    tags: ['vegetarian', 'spicy', 'comfort-food'],
    rating: 4.6,
  },
  {
    title: 'Bruschetta',
    cuisine: 'Italian',
    servings: 4,
    prepTimeMinutes: 15,
    cookTimeMinutes: 5,
    difficulty: 'easy',
    ingredients: [
      { name: 'baguette', amount: 1, unit: 'whole' },
      { name: 'tomatoes', amount: 4, unit: 'whole' },
      { name: 'garlic', amount: 2, unit: 'cloves' },
      { name: 'basil', amount: 8, unit: 'leaves' },
      { name: 'olive oil', amount: 3, unit: 'tbsp' },
    ],
    tags: ['vegetarian', 'quick', 'party', 'appetizer'],
    rating: 4.4,
  },
  {
    title: 'Ramen',
    cuisine: 'Japanese',
    servings: 2,
    prepTimeMinutes: 20,
    cookTimeMinutes: 45,
    difficulty: 'medium',
    ingredients: [
      { name: 'ramen noodles', amount: 200, unit: 'g' },
      { name: 'pork belly', amount: 300, unit: 'g' },
      { name: 'soy sauce', amount: 3, unit: 'tbsp' },
      { name: 'eggs', amount: 2, unit: 'whole' },
      { name: 'garlic', amount: 3, unit: 'cloves' },
      { name: 'green onion', amount: 2, unit: 'stalks' },
    ],
    tags: ['comfort-food', 'protein-rich', 'umami'],
    rating: 4.9,
  },
  {
    title: 'Churros',
    cuisine: 'Mexican',
    servings: 6,
    prepTimeMinutes: 15,
    cookTimeMinutes: 20,
    difficulty: 'medium',
    ingredients: [
      { name: 'flour', amount: 1, unit: 'cup' },
      { name: 'butter', amount: 2, unit: 'tbsp' },
      { name: 'eggs', amount: 1, unit: 'whole' },
      { name: 'sugar', amount: 0.5, unit: 'cup' },
      { name: 'cinnamon', amount: 1, unit: 'tsp' },
    ],
    tags: ['dessert', 'party', 'sweet'],
    rating: 4.5,
  },
  {
    title: 'Butter Chicken',
    cuisine: 'Indian',
    servings: 4,
    prepTimeMinutes: 20,
    cookTimeMinutes: 35,
    difficulty: 'medium',
    ingredients: [
      { name: 'chicken thighs', amount: 600, unit: 'g' },
      { name: 'butter', amount: 3, unit: 'tbsp' },
      { name: 'tomato sauce', amount: 2, unit: 'cups' },
      { name: 'cream', amount: 0.5, unit: 'cup' },
      { name: 'garlic', amount: 4, unit: 'cloves' },
      { name: 'ginger', amount: 1, unit: 'tbsp' },
      { name: 'garam masala', amount: 2, unit: 'tbsp' },
    ],
    tags: ['comfort-food', 'protein-rich', 'creamy'],
    rating: 4.8,
  },
]

Each recipe has an ingredients array where every element is an object with name, amount, and unit. The tags array varies in length. In a relational database you'd need at least two junction tables and several joins to represent this. In MongoDB, it's just a document.

Connect and Insert

Connect and insert the dataset:

typescript
import { MongoClient } from 'mongodb'

const client = new MongoClient('mongodb://localhost:27017')
await client.connect()

const db = client.db('cookbook')
const collection = db.collection('recipes')

// Clean up from previous runs
await collection.drop().catch(() => {})

const result = await collection.insertMany(recipes)
console.log(`Inserted ${result.insertedCount} recipes`)

MongoClient connects to the server, db() gets a database reference (created automatically if it doesn't exist), and insertMany puts all 12 documents in at once.

A basic find with a projection to return only specific fields:

typescript
const italianNames = await collection
  .find({ cuisine: 'Italian' }, { projection: { title: 1, rating: 1, _id: 0 } })
  .toArray()

console.log('\nItalian recipes (title and rating only):')
for (const r of italianNames) {
  console.log(`  ${r.title} - ${r.rating} stars`)
}
text
Inserted 12 recipes

Italian recipes (title and rating only):
  Margherita Pizza - 4.8 stars
  Pasta Carbonara - 4.9 stars
  Bruschetta - 4.4 stars

Projections control which fields come back. Setting a field to 1 includes it; _id: 0 excludes the auto-generated ID. Worth doing when you only need a few fields from large documents.

Flexible Queries

MongoDB supports field matching, comparison operators, and logical combinations. This is where it starts to feel different from SQL:

typescript
// Easy Italian recipes
const easyItalian = await collection
  .find({ cuisine: 'Italian', difficulty: 'easy' })
  .toArray()

console.log('\nEasy Italian recipes:')
for (const r of easyItalian) {
  console.log(`  ${r.title} (${r.prepTimeMinutes + r.cookTimeMinutes} min total)`)
}

// Recipes ready in 20 minutes or less (prep + cook)
const quickRecipes = await collection
  .find({ prepTimeMinutes: { $lte: 10 }, cookTimeMinutes: { $lte: 15 } })
  .sort({ rating: -1 })
  .toArray()

console.log('\nQuick recipes (10 min prep, 15 min cook or less):')
for (const r of quickRecipes) {
  console.log(`  ${r.title} - ${r.cuisine} (${r.prepTimeMinutes + r.cookTimeMinutes} min)`)
}

// Recipes tagged "vegetarian" or "quick" using $in
const vegOrQuick = await collection
  .find({ tags: { $in: ['vegetarian', 'quick'] } })
  .sort({ rating: -1 })
  .toArray()

console.log('\nVegetarian or quick recipes:')
for (const r of vegOrQuick) {
  console.log(`  ${r.title} - tags: ${r.tags.join(', ')}`)
}
text
Easy Italian recipes:
  Margherita Pizza (35 min total)
  Bruschetta (20 min total)

Quick recipes (10 min prep, 15 min cook or less):
  Pasta Carbonara - Italian (25 min)
  Guacamole - Mexican (10 min)
  Miso Soup - Japanese (15 min)

Vegetarian or quick recipes:
  Pasta Carbonara - tags: quick, comfort-food, protein-rich
  Margherita Pizza - tags: vegetarian, quick, comfort-food
  Guacamole - tags: vegetarian, quick, party, no-cook
  Palak Paneer - tags: vegetarian, spicy, comfort-food
  Bruschetta - tags: vegetarian, quick, party, appetizer
  Miso Soup - tags: vegetarian, quick, healthy, comfort-food

$lte, $in, field matching, .sort(): expressive queries without writing SQL. $in is especially useful with array fields, matching any document where the array contains at least one of the specified values.

Querying Nested Arrays

SQL can't do this naturally without JSON functions or lateral joins. MongoDB lets you query into arrays of objects with dot notation:

typescript
// Recipes that use garlic
const garlicRecipes = await collection
  .find({ 'ingredients.name': 'garlic' })
  .toArray()

console.log('\nRecipes that use garlic:')
for (const r of garlicRecipes) {
  const garlic = r.ingredients.find(
    (i: { name: string }) => i.name === 'garlic',
  )
  console.log(`  ${r.title} - ${garlic.amount} ${garlic.unit}`)
}

// Recipes that use more than 200g of a single ingredient
const heartyRecipes = await collection
  .find({
    ingredients: {
      $elemMatch: { amount: { $gt: 200 }, unit: 'g' },
    },
  })
  .toArray()

console.log('\nRecipes with a 200g+ ingredient:')
for (const r of heartyRecipes) {
  const heavy = r.ingredients.filter(
    (i: { amount: number; unit: string }) => i.amount > 200 && i.unit === 'g',
  )
  for (const h of heavy) {
    console.log(`  ${r.title}: ${h.name} (${h.amount}${h.unit})`)
  }
}
text
Recipes that use garlic:
  Chicken Tikka Masala - 4 cloves
  Guacamole - 2 cloves
  Pasta Carbonara - 2 cloves
  Tacos al Pastor - 3 cloves
  Palak Paneer - 3 cloves
  Bruschetta - 2 cloves
  Ramen - 3 cloves
  Butter Chicken - 4 cloves

Recipes with a 200g+ ingredient:
  Chicken Tikka Masala: chicken breast (500g)
  Ramen: pork belly (300g)
  Tacos al Pastor: pork shoulder (500g)
  Palak Paneer: spinach (500g)
  Palak Paneer: paneer (250g)
  Butter Chicken: chicken thighs (600g)

'ingredients.name': 'garlic' reaches into every object in the ingredients array and checks its name field. No joins, no unnesting, no subqueries. MongoDB treats nested structures as queryable at any depth.

Aggregation Pipeline

The aggregation framework lets you build multi-stage data transformations. Each stage takes the output of the previous one, like piping commands in a shell.

Average Rating by Cuisine

typescript
const cuisineStats = await collection
  .aggregate([
    {
      $group: {
        _id: '$cuisine',
        avgRating: { $avg: '$rating' },
        count: { $sum: 1 },
      },
    },
    { $sort: { avgRating: -1 } },
  ])
  .toArray()

console.log('\nAverage rating by cuisine:')
console.log('  Cuisine       Avg Rating  Recipes')
for (const stat of cuisineStats) {
  const avg = (stat.avgRating as number).toFixed(2)
  console.log(
    `  ${String(stat._id).padEnd(14)} ${avg.padStart(5)}       ${stat.count}`,
  )
}
text
Average rating by cuisine:
  Cuisine       Avg Rating  Recipes
  Italian         4.70       3
  Japanese        4.57       3
  Indian          4.70       3
  Mexican         4.63       3

Most Common Ingredients

$unwind flattens an array field so each element becomes its own document, letting you aggregate across all ingredients from all recipes:

typescript
const commonIngredients = await collection
  .aggregate([
    { $unwind: '$ingredients' },
    {
      $group: {
        _id: '$ingredients.name',
        recipeCount: { $sum: 1 },
      },
    },
    { $sort: { recipeCount: -1 } },
    { $limit: 5 },
  ])
  .toArray()

console.log('\nTop 5 most common ingredients:')
for (const ing of commonIngredients) {
  console.log(`  ${String(ing._id).padEnd(16)} used in ${ing.recipeCount} recipes`)
}
text
Top 5 most common ingredients:
  garlic           used in 8 recipes
  onion            used in 3 recipes
  eggs             used in 3 recipes
  ginger           used in 3 recipes
  tomato sauce     used in 3 recipes

Computed Fields with $addFields

$addFields computes new values from existing fields:

typescript
const withTotalTime = await collection
  .aggregate([
    {
      $addFields: {
        totalTimeMinutes: { $add: ['$prepTimeMinutes', '$cookTimeMinutes'] },
      },
    },
    { $sort: { totalTimeMinutes: 1 } },
    { $project: { title: 1, totalTimeMinutes: 1, _id: 0 } },
  ])
  .toArray()

console.log('\nRecipes by total time:')
for (const r of withTotalTime) {
  console.log(`  ${String(r.totalTimeMinutes).padStart(3)} min - ${r.title}`)
}
text
Recipes by total time:
   10 min - Guacamole
   15 min - Miso Soup
   20 min - Bruschetta
   25 min - Pasta Carbonara
   35 min - Margherita Pizza
   35 min - Churros
   40 min - Palak Paneer
   55 min - Butter Chicken
   65 min - Ramen
   65 min - Tacos al Pastor
   65 min - Sushi Rolls
   70 min - Chicken Tikka Masala

$add sums prepTimeMinutes and cookTimeMinutes into a new field. The pipeline sorts by total time and $project keeps only the fields we need.

Text Search

MongoDB has built-in text search. Create a text index on title and tags, then search across both:

typescript
await collection.createIndex({ title: 'text', tags: 'text' })

const searchResults = await collection
  .find({ $text: { $search: 'quick vegetarian' } })
  .project({ title: 1, tags: 1, score: { $meta: 'textScore' }, _id: 0 })
  .sort({ score: { $meta: 'textScore' } })
  .toArray()

console.log('\nText search for "quick vegetarian":')
for (const r of searchResults) {
  console.log(`  ${(r.score as number).toFixed(2)}  ${r.title} - [${r.tags.join(', ')}]`)
}
text
Text search for "quick vegetarian":
  1.50  Miso Soup - [vegetarian, quick, healthy, comfort-food]
  1.50  Bruschetta - [vegetarian, quick, party, appetizer]
  1.50  Margherita Pizza - [vegetarian, quick, comfort-food]
  1.50  Guacamole - [vegetarian, quick, party, no-cook]
  1.00  Palak Paneer - [vegetarian, spicy, comfort-food]
  0.75  Pasta Carbonara - [quick, comfort-food, protein-rich]

The text index tokenizes and stems content, handling plurals and basic word variations. Documents matching both terms score higher. For typo tolerance, faceting, and ranking tuning, a dedicated search engine like Meilisearch is a better fit. But for keyword search within your database, MongoDB's text indexes do the job.

Update Operators

Update operators modify documents without replacing them entirely. Especially useful for adding to arrays, incrementing counters, and updating nested fields:

typescript
// Add a tag to Margherita Pizza
await collection.updateOne(
  { title: 'Margherita Pizza' },
  { $push: { tags: 'family-friendly' } },
)

// Verify the tag was added
const pizza = await collection.findOne(
  { title: 'Margherita Pizza' },
  { projection: { title: 1, tags: 1, _id: 0 } },
)
console.log('\nAfter $push:')
console.log(`  ${pizza?.title} - [${pizza?.tags.join(', ')}]`)

// Update the servings for Ramen using $set
await collection.updateOne(
  { title: 'Ramen' },
  { $set: { servings: 3, difficulty: 'hard' } },
)

const ramen = await collection.findOne(
  { title: 'Ramen' },
  { projection: { title: 1, servings: 1, difficulty: 1, _id: 0 } },
)
console.log('\nAfter $set:')
console.log(`  ${ramen?.title} - ${ramen?.servings} servings, ${ramen?.difficulty}`)

// Increment servings for all Mexican recipes
await collection.updateMany(
  { cuisine: 'Mexican' },
  { $inc: { servings: 2 } },
)

const mexican = await collection
  .find({ cuisine: 'Mexican' }, { projection: { title: 1, servings: 1, _id: 0 } })
  .toArray()

console.log('\nAfter $inc on Mexican recipes:')
for (const r of mexican) {
  console.log(`  ${r.title} - ${r.servings} servings`)
}
text
After $push:
  Margherita Pizza - [vegetarian, quick, comfort-food, family-friendly]

After $set:
  Ramen - 3 servings, hard

After $inc on Mexican recipes:
  Guacamole - 8 servings
  Tacos al Pastor - 6 servings
  Churros - 8 servings

$push appends to an array. $set replaces specific fields, leaving everything else alone. $inc adds to a numeric field. All atomic at the document level, so concurrent updates to different fields won't conflict.

Run the full script:

bash
npx tsx recipes.ts

Close the connection at the end of the file:

typescript
await client.close()

When to Use MongoDB

MongoDB is the right fit when:

  • Your data has flexible or evolving schemas. Adding a field doesn't require a migration. Documents in the same collection can have different structures, so your data model evolves alongside your application.
  • You're building content management. Blog posts, articles, and pages have varying metadata, embedded media, and nested structures. Documents map naturally to this.
  • Product catalogs have varying attributes. A laptop needs ramGB and screenSize. A shirt needs material and fit. Each document carries exactly the fields it needs.
  • Event logs need high write throughput. Log entries vary in structure and arrive fast. MongoDB handles schemaless writes at scale without requiring you to define every possible field shape upfront.
  • You're prototyping. No migrations, no schema definitions, no ALTER TABLE. Store whatever your application produces and iterate on structure as requirements solidify.
  • Your application objects map naturally to documents. If your code already works with nested objects and arrays, storing them directly eliminates the ORM layer entirely.

Wrapping Up

The full script covers insertion, projections, flexible queries, dot-notation array queries, three aggregation pipelines, text search, and update operators. Same patterns scale from 12 recipes to millions of documents.

The MongoDB documentation covers change streams, transactions, sharding, replica sets, and schema validation.

To manage your local MongoDB instance:

bash
spindb stop mongo1    # Stop the server
spindb start mongo1   # Start it again
spindb list           # See all your database instances

SpinDB supports 20+ engines, so you can run MongoDB alongside Redis for caching, PostgreSQL for relational data, or Meilisearch for search, all from the same CLI.

Something not working?