Getting Started with MongoDB
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
- Set Up the Project
- The Recipe Dataset
- Connect and Insert
- Flexible Queries
- Querying Nested Arrays
- Aggregation Pipeline
- Text Search
- Update Operators
- When to Use MongoDB
- Wrapping Up
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:
npm i -g spindb # npm
pnpm add -g spindb # pnpmOr run it directly without installing:
npx spindb create mongo1 -e mongodb --start # npm
pnpx spindb create mongo1 -e mongodb --start # pnpmIf you installed globally, create and start a MongoDB instance:
spindb create mongo1 -e mongodb --startSpinDB downloads the MongoDB binary, configures it, and starts the server. Verify it's running:
spindb url mongo1mongodb://127.0.0.1:27017Leave 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:
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
mkdir mongodb-recipes && cd mongodb-recipes
pnpm init
pnpm add mongodb
pnpm add -D tsx typescriptCreate 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:
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:
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:
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`)
}Inserted 12 recipes
Italian recipes (title and rating only):
Margherita Pizza - 4.8 stars
Pasta Carbonara - 4.9 stars
Bruschetta - 4.4 starsProjections 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:
// 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(', ')}`)
}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:
// 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})`)
}
}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
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}`,
)
}Average rating by cuisine:
Cuisine Avg Rating Recipes
Italian 4.70 3
Japanese 4.57 3
Indian 4.70 3
Mexican 4.63 3Most Common Ingredients
$unwind flattens an array field so each element becomes its own document, letting you aggregate across all ingredients from all recipes:
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`)
}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 recipesComputed Fields with $addFields
$addFields computes new values from existing fields:
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}`)
}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:
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 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:
// 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`)
}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:
npx tsx recipes.tsClose the connection at the end of the file:
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
ramGBandscreenSize. A shirt needsmaterialandfit. 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:
spindb stop mongo1 # Stop the server
spindb start mongo1 # Start it again
spindb list # See all your database instancesSpinDB 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.