Build a Recipe Saver Chrome Extension: Complete 2025 Developer's Guide

52 min read

Build a Recipe Saver Chrome Extension: Complete 2025 Developer's Guide

Build a Recipe Saver Chrome Extension: Complete 2025 Developer’s Guide

The art of cooking has been revolutionized by the internet, with millions of recipes available at our fingertips. However, users often struggle to save and organize recipes from various websites into a single, accessible location. A well-designed recipe saver Chrome extension can solve this problem by allowing users to capture recipes with a single click, automatically extracting ingredients, instructions, and metadata while providing robust organization features.

This comprehensive guide walks you through building a production-ready recipe saver extension using Chrome’s Manifest V3 standards. Whether you are a beginner to Chrome extension development or an experienced developer looking to expand your portfolio, this tutorial provides everything you need to create a powerful recipe management tool that users will love.


Why Build a Recipe Saver Chrome Extension

The demand for recipe management tools continues to grow as more people turn to online resources for cooking inspiration. A recipe saver extension offers unique advantages over standalone applications and bookmarking systems because it can intelligently extract recipe data from web pages, eliminating the need for manual copying and pasting.

Building a recipe saver extension in 2025 means working with Manifest V3, which introduced significant changes to how Chrome extensions operate. These changes include new restrictions on background scripts, modifications to content script capabilities, and enhanced privacy controls. Understanding these changes is essential for creating an extension that passes Chrome’s review process and provides a seamless user experience.

The cooking extension market presents a significant opportunity for developers. Users frequently visit recipe blogs, food websites, and cooking platforms but often lose track of recipes they want to try. A dedicated recipe saver extension can capture not just the URL, but the actual recipe content including ingredients, measurements, cooking time, and step-by-step instructions. This makes the extension far more valuable than simple bookmarking.


Project Planning and Feature Definition

Before writing any code, you need to define the core features your recipe saver extension will provide. A basic recipe saver should allow users to save recipes with one click, view their saved collection, and access recipes offline. More advanced features might include recipe categorization, search functionality, meal planning, shopping list generation, and cross-device synchronization.

For this guide, we will build a comprehensive extension with the following features:

  • One-click recipe saving from any web page
  • Automatic ingredient and instruction extraction
  • Recipe organization with tags and categories
  • Local storage for offline access
  • Full-text search across saved recipes
  • Recipe detail view with cooking mode
  • Export recipes to various formats
  • Meal planning calendar integration

This feature set provides enough complexity to demonstrate advanced Chrome extension concepts while remaining achievable within a single tutorial. You can expand these features later based on user feedback and market demands.

Technology Stack

Our recipe saver extension will use the following technologies:

  • Manifest V3: The latest Chrome extension manifest format
  • HTML/CSS/JavaScript: Standard web technologies for the popup and options pages
  • Chrome Storage API: For persistent data storage
  • Content Scripts: For recipe extraction from web pages
  • DOM Parsing: For intelligent recipe data extraction

Setting Up the Project Structure

Every Chrome extension begins with a well-organized project structure. Create a new folder for your extension and set up the following files and directories:

recipe-saver/
├── manifest.json
├── popup/
│   ├── popup.html
│   ├── popup.css
│   └── popup.js
├── content/
│   └── content.js
├── background/
│   └── background.js
├── options/
│   ├── options.html
│   ├── options.css
│   └── options.js
├── icons/
│   ├── icon16.png
│   ├── icon48.png
│   └── icon128.png
└── styles/
    └── content-style.css

This structure separates different parts of your extension clearly, making it easier to maintain and expand. The popup directory contains the extension’s main interface, content scripts handle web page interaction, background scripts manage long-running tasks, and the options directory provides settings configuration.


Creating the Manifest V3 Configuration

The manifest.json file is the heart of every Chrome extension. It defines the extension’s capabilities, permissions, and configuration. For our recipe saver extension, we need to specify the appropriate permissions for storage, active tab access, and script injection.

{
  "manifest_version": 3,
  "name": "Recipe Saver - Save Recipes from Any Website",
  "version": "1.0.0",
  "description": "Save and organize recipes from any website with one click. Extract ingredients, instructions, and more.",
  "permissions": [
    "storage",
    "activeTab",
    "scripting"
  ],
  "host_permissions": [
    "<all_urls>"
  ],
  "action": {
    "default_popup": "popup/popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "background": {
    "service_worker": "background/background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content/content.js"],
      "css": ["styles/content-style.css"],
      "run_at": "document_idle"
    }
  ],
  "options_page": "options/options.html",
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

This manifest configuration grants the extension the permissions it needs to function properly. The storage permission allows saving recipes locally, activeTab enables interaction with the current page, and scripting permits content script injection. The host permissions with <all_urls> enable the extension to work on any website where users might find recipes.


The popup is the primary user interface for most Chrome extensions. For our recipe saver, the popup will display the saved recipes list, provide quick save functionality, and offer access to the full recipe collection.

Create the popup HTML file:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Recipe Saver</title>
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <div class="popup-container">
    <header class="popup-header">
      <h1>Recipe Saver</h1>
      <button id="saveCurrentBtn" class="save-btn">Save This Recipe</button>
    </header>
    
    <div class="search-container">
      <input type="text" id="searchInput" placeholder="Search recipes...">
    </div>
    
    <div id="recipeList" class="recipe-list">
      <!-- Recipes will be dynamically inserted here -->
    </div>
    
    <footer class="popup-footer">
      <button id="viewAllBtn" class="link-btn">View All Recipes</button>
      <button id="optionsBtn" class="link-btn">Settings</button>
    </footer>
  </div>
  
  <script src="popup.js"></script>
</body>
</html>

The popup interface includes a header with the save button, a search input for finding recipes, a scrollable recipe list, and footer links to additional features. This layout provides quick access to the extension’s core functionality while remaining compact and unobtrusive.

Style the popup with CSS to create an attractive, modern interface:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  width: 360px;
  min-height: 400px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
  background-color: #ffffff;
  color: #333;
}

.popup-container {
  padding: 16px;
}

.popup-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid #e0e0e0;
}

.popup-header h1 {
  font-size: 18px;
  font-weight: 600;
  color: #2c3e50;
}

.save-btn {
  background-color: #27ae60;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 6px;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.save-btn:hover {
  background-color: #219a52;
}

.save-btn:disabled {
  background-color: #95a5a6;
  cursor: not-allowed;
}

.search-container {
  margin-bottom: 12px;
}

#searchInput {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
  outline: none;
  transition: border-color 0.2s ease;
}

#searchInput:focus {
  border-color: #27ae60;
}

.recipe-list {
  max-height: 280px;
  overflow-y: auto;
}

.recipe-item {
  display: flex;
  align-items: center;
  padding: 10px;
  margin-bottom: 8px;
  background-color: #f8f9fa;
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.recipe-item:hover {
  background-color: #e9ecef;
}

.recipe-thumbnail {
  width: 48px;
  height: 48px;
  border-radius: 4px;
  object-fit: cover;
  margin-right: 12px;
  background-color: #dee2e6;
}

.recipe-info {
  flex: 1;
  min-width: 0;
}

.recipe-title {
  font-size: 14px;
  font-weight: 500;
  color: #2c3e50;
  margin-bottom: 4px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.recipe-meta {
  font-size: 12px;
  color: #6c757d;
}

.recipe-delete {
  background: none;
  border: none;
  color: #dc3545;
  cursor: pointer;
  padding: 4px 8px;
  font-size: 16px;
  opacity: 0.6;
  transition: opacity 0.2s ease;
}

.recipe-delete:hover {
  opacity: 1;
}

.popup-footer {
  display: flex;
  justify-content: space-between;
  padding-top: 12px;
  margin-top: 12px;
  border-top: 1px solid #e0e0e0;
}

.link-btn {
  background: none;
  border: none;
  color: #3498db;
  font-size: 13px;
  cursor: pointer;
  padding: 4px 8px;
}

.link-btn:hover {
  text-decoration: underline;
}

.empty-state {
  text-align: center;
  padding: 40px 20px;
  color: #6c757d;
}

.empty-state-icon {
  font-size: 48px;
  margin-bottom: 12px;
}

The CSS provides a clean, modern design with a green color scheme appropriate for a cooking-related application. The interface includes proper spacing, hover states, and visual feedback for interactive elements.


The popup JavaScript handles user interactions and communicates with other parts of the extension. Create the popup.js file:

document.addEventListener('DOMContentLoaded', () => {
  const saveCurrentBtn = document.getElementById('saveCurrentBtn');
  const searchInput = document.getElementById('searchInput');
  const recipeList = document.getElementById('recipeList');
  const viewAllBtn = document.getElementById('viewAllBtn');
  const optionsBtn = document.getElementById('optionsBtn');

  let allRecipes = [];

  // Load recipes from storage
  async function loadRecipes() {
    return new Promise((resolve) => {
      chrome.storage.local.get(['recipes'], (result) => {
        resolve(result.recipes || []);
      });
    });
  }

  // Save recipe to storage
  async function saveRecipe(recipe) {
    return new Promise((resolve) => {
      chrome.storage.local.get(['recipes'], (result) => {
        const recipes = result.recipes || [];
        
        // Check if recipe already exists
        const exists = recipes.some(r => r.url === recipe.url);
        if (exists) {
          resolve(false);
          return;
        }
        
        recipes.unshift(recipe);
        chrome.storage.local.set({ recipes }, () => resolve(true));
      });
    });
  }

  // Delete recipe from storage
  async function deleteRecipe(url) {
    return new Promise((resolve) => {
      chrome.storage.local.get(['recipes'], (result) => {
        const recipes = result.recipes || [];
        const filtered = recipes.filter(r => r.url !== url);
        chrome.storage.local.set({ recipes: filtered }, () => resolve());
      });
    });
  }

  // Render recipe list
  function renderRecipes(recipes) {
    if (recipes.length === 0) {
      recipeList.innerHTML = `
        <div class="empty-state">
          <div class="empty-state-icon">📖</div>
          <p>No recipes saved yet</p>
          <p style="font-size: 12px; margin-top: 8px;">Click "Save This Recipe" on any recipe page</p>
        </div>
      `;
      return;
    }

    recipeList.innerHTML = recipes.map(recipe => `
      <div class="recipe-item" data-url="${recipe.url}">
        <img src="${recipe.image || 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22%3E%3Crect fill=%22%23dee2e6%22 width=%2248%22 height=%2248%22/%3E%3Ctext x=%2250%25%22 y=%2250%25%22 dominant-baseline=%22middle%22 text-anchor=%22middle%22 fill=%22%236c757d%22%3E🍳%3C/text%3E%3C/svg%3E'}" 
             alt="${recipe.title}" 
             class="recipe-thumbnail"
             onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22%3E%3Crect fill=%22%23dee2e6%22 width=%2248%22 height=%2248%22/%3E%3Ctext x=%2250%25%22 y=%2250%25%22 dominant-baseline=%22middle%22 text-anchor=%22middle%22 fill=%22%236c757d%22%3E🍳%3C/text%3E%3C/svg%3E'">
        <div class="recipe-info">
          <div class="recipe-title">${recipe.title}</div>
          <div class="recipe-meta">${recipe.source || 'Unknown source'}${recipe.saveDate || 'Recently'}</div>
        </div>
        <button class="recipe-delete" data-url="${recipe.url}">✕</button>
      </div>
    `).join('');

    // Add click handlers
    document.querySelectorAll('.recipe-item').forEach(item => {
      item.addEventListener('click', (e) => {
        if (!e.target.classList.contains('recipe-delete')) {
          const url = item.dataset.url;
          chrome.tabs.create({ url });
        }
      });
    });

    document.querySelectorAll('.recipe-delete').forEach(btn => {
      btn.addEventListener('click', async (e) => {
        e.stopPropagation();
        const url = btn.dataset.url;
        await deleteRecipe(url);
        allRecipes = allRecipes.filter(r => r.url !== url);
        renderRecipes(allRecipes);
      });
    });
  }

  // Save current page recipe
  saveCurrentBtn.addEventListener('click', async () => {
    saveCurrentBtn.disabled = true;
    saveCurrentBtn.textContent = 'Saving...';

    try {
      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
      
      // Send message to content script to extract recipe
      const response = await chrome.tabs.sendMessage(tab.id, { action: 'extractRecipe' });
      
      if (response && response.recipe) {
        const recipe = {
          ...response.recipe,
          url: tab.url,
          saveDate: new Date().toLocaleDateString()
        };
        
        const saved = await saveRecipe(recipe);
        
        if (saved) {
          saveCurrentBtn.textContent = 'Saved!';
          allRecipes.unshift(recipe);
          renderRecipes(allRecipes);
          
          setTimeout(() => {
            saveCurrentBtn.textContent = 'Save This Recipe';
            saveCurrentBtn.disabled = false;
          }, 2000);
        } else {
          saveCurrentBtn.textContent = 'Already Saved';
          setTimeout(() => {
            saveCurrentBtn.textContent = 'Save This Recipe';
            saveCurrentBtn.disabled = false;
          }, 2000);
        }
      } else {
        saveCurrentBtn.textContent = 'No Recipe Found';
        setTimeout(() => {
          saveCurrentBtn.textContent = 'Save This Recipe';
          saveCurrentBtn.disabled = false;
        }, 2000);
      }
    } catch (error) {
      console.error('Error saving recipe:', error);
      saveCurrentBtn.textContent = 'Error';
      setTimeout(() => {
        saveCurrentBtn.textContent = 'Save This Recipe';
        saveCurrentBtn.disabled = false;
      }, 2000);
    }
  });

  // Search functionality
  searchInput.addEventListener('input', (e) => {
    const query = e.target.value.toLowerCase();
    const filtered = allRecipes.filter(recipe => 
      recipe.title.toLowerCase().includes(query) ||
      (recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(query)))
    );
    renderRecipes(filtered);
  });

  // View all recipes
  viewAllBtn.addEventListener('click', () => {
    chrome.tabs.create({ url: 'options/options.html?view=all' });
  });

  // Open options
  optionsBtn.addEventListener('click', () => {
    chrome.tabs.create({ url: 'options/options.html' });
  });

  // Initialize
  loadRecipes().then(recipes => {
    allRecipes = recipes;
    renderRecipes(recipes);
  });
});

This JavaScript code handles all the popup functionality including loading and saving recipes, searching, and user interactions. It communicates with the content script to extract recipe data from the current page.


Creating the Content Script for Recipe Extraction

The content script runs on web pages and is responsible for extracting recipe data. This is the most complex part of the extension as it must handle various website structures and formats.

// Content script for recipe extraction

// Listen for messages from popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'extractRecipe') {
    const recipe = extractRecipeFromPage();
    sendResponse({ recipe });
  }
  return true;
});

// Main recipe extraction function
function extractRecipeFromPage() {
  // Try multiple extraction strategies
  
  // Strategy 1: JSON-LD structured data (most reliable)
  const jsonLdRecipe = extractFromJsonLd();
  if (jsonLdRecipe) return jsonLdRecipe;
  
  // Strategy 2: Microdata/RDFa
  const microdataRecipe = extractFromMicrodata();
  if (microdataRecipe) return microdataRecipe;
  
  // Strategy 3: Common CSS class patterns
  const classBasedRecipe = extractFromClasses();
  if (classBasedRecipe) return classBasedRecipe;
  
  // Strategy 4: Generic fallback
  return extractGenericRecipe();
}

// Extract recipe from JSON-LD structured data
function extractFromJsonLd() {
  const scripts = document.querySelectorAll('script[type="application/ld+json"]');
  
  for (const script of scripts) {
    try {
      const data = JSON.parse(script.textContent);
      const recipe = findRecipeInJsonLd(data);
      if (recipe) return recipe;
    } catch (e) {
      continue;
    }
  }
  
  return null;
}

// Find recipe object in JSON-LD data
function findRecipeInJsonLd(data) {
  if (!data) return null;
  
  // Handle array of objects
  if (Array.isArray(data)) {
    for (const item of data) {
      const recipe = findRecipeInJsonLd(item);
      if (recipe) return recipe;
    }
    return null;
  }
  
  // Check if this is a Recipe type
  if (data['@type'] === 'Recipe' || 
      (Array.isArray(data['@type']) && data['@type'].includes('Recipe'))) {
    return parseJsonLdRecipe(data);
  }
  
  // Check for @graph array
  if (data['@graph']) {
    return findRecipeInJsonLd(data['@graph']);
  }
  
  return null;
}

// Parse JSON-LD recipe data
function parseJsonLdRecipe(data) {
  const recipe = {
    title: data.name || document.title,
    description: data.description || '',
    image: extractImage(data.image),
    ingredients: extractIngredients(data.recipeIngredient),
    instructions: extractInstructions(data.recipeInstructions),
    prepTime: data.prepTime || data.prepTimeInMinutes,
    cookTime: data.cookTime || data.cookTimeInMinutes,
    totalTime: data.totalTime || data.totalTimeInMinutes,
    servings: data.recipeYield || data.yields,
    source: data.author?.name || window.location.hostname,
    url: window.location.href
  };
  
  return recipe;
}

// Extract image URL from various formats
function extractImage(imageData) {
  if (!imageData) return null;
  if (typeof imageData === 'string') return imageData;
  if (Array.isArray(imageData)) {
    if (typeof imageData[0] === 'string') return imageData[0];
    return imageData[0]?.url || imageData[0]?.contentUrl;
  }
  return imageData.url || imageData.contentUrl;
}

// Extract ingredients list
function extractIngredients(ingredients) {
  if (!ingredients) return [];
  if (typeof ingredients === 'string') return [ingredients];
  return ingredients;
}

// Extract instructions
function extractInstructions(instructions) {
  if (!instructions) return [];
  if (typeof instructions === 'string') return [instructions];
  if (Array.isArray(instructions)) {
    return instructions.map(instruction => {
      if (typeof instruction === 'string') return instruction;
      if (instruction['@type'] === 'HowToStep') return instruction.text;
      if (instruction['@type'] === 'HowToSection') {
        return instruction.itemListElement?.map(s => s.text).join('\n');
      }
      return instruction.text || '';
    }).filter(Boolean);
  }
  return [];
}

// Extract from Microdata
function extractFromMicrodata() {
  const recipeElement = document.querySelector('[itemtype*="schema.org/Recipe"]');
  if (!recipeElement) return null;
  
  const getValue = (prop) => {
    const el = recipeElement.querySelector(`[itemprop="${prop}"]`);
    if (!el) return null;
    if (el.tagName === 'META') return el.getAttribute('content');
    return el.textContent.trim();
  };
  
  const getValues = (prop) => {
    const els = recipeElement.querySelectorAll(`[itemprop="${prop}"]`);
    return Array.from(els).map(el => {
      if (el.tagName === 'META') return el.getAttribute('content');
      return el.textContent.trim();
    }).filter(Boolean);
  };
  
  return {
    title: getValue('name') || document.title,
    description: getValue('description') || '',
    image: getValue('image'),
    ingredients: getValues('recipeIngredient'),
    instructions: extractInstructionsFromHtml(recipeElement.querySelector('[itemprop="recipeInstructions"]')),
    prepTime: getValue('prepTime'),
    cookTime: getValue('cookTime'),
    totalTime: getValue('totalTime'),
    servings: getValue('recipeYield'),
    source: window.location.hostname,
    url: window.location.href
  };
}

// Extract instructions from HTML content
function extractInstructionsFromHtml(element) {
  if (!element) return [];
  
  const steps = element.querySelectorAll('[itemprop="step"], .recipe-steps li, .instructions li, .steps li, ol li');
  if (steps.length > 0) {
    return Array.from(steps).map(s => s.textContent.trim()).filter(Boolean);
  }
  
  return [element.textContent.trim()];
}

// Extract using common CSS class patterns
function extractFromClasses() {
  // Common patterns for recipe websites
  const titleSelectors = [
    '.recipe-title', '.recipe-name', '.entry-title', 'h1.recipe-title',
    '.wprm-recipe-name', '.tasty-recipes-title', '.mv-create-title'
  ];
  
  const ingredientSelectors = [
    '.recipe-ingredients li', '.ingredients li', '.ingredient-list li',
    '.wprm-recipe-ingredient', '.tasty-recipes-ingredients li'
  ];
  
  const instructionSelectors = [
    '.recipe-instructions li', '.instructions li', '.directions li',
    '.wprm-recipe-instruction', '.tasty-recipes-steps li', '.recipe-steps li'
  ];
  
  const title = querySelectorText(titleSelectors) || document.title;
  const ingredients = querySelectorAllText(ingredientSelectors);
  const instructions = querySelectorAllText(instructionSelectors);
  
  if (ingredients.length > 0 || instructions.length > 0) {
    return {
      title,
      ingredients,
      instructions,
      source: window.location.hostname,
      url: window.location.href
    };
  }
  
  return null;
}

// Helper function to query selectors
function querySelectorText(selectors) {
  for (const selector of selectors) {
    const el = document.querySelector(selector);
    if (el) return el.textContent.trim();
  }
  return null;
}

function querySelectorAllText(selectors) {
  for (const selector of selectors) {
    const els = document.querySelectorAll(selector);
    if (els.length > 0) {
      return Array.from(els).map(el => el.textContent.trim()).filter(Boolean);
    }
  }
  return [];
}

// Generic fallback extraction
function extractGenericRecipe() {
  // Try to find any list that might be ingredients
  const allLists = document.querySelectorAll('ul, ol');
  let ingredients = [];
  let instructions = [];
  
  for (const list of allLists) {
    const text = list.textContent.toLowerCase();
    if (text.includes('cup') || text.includes('tsp') || text.includes('tbsp') || 
        text.includes('tablespoon') || text.includes('teaspoon') || text.includes('oz')) {
      const items = Array.from(list.querySelectorAll('li')).map(li => li.textContent.trim());
      if (ingredients.length === 0 || items.length > ingredients.length) {
        ingredients = items;
      }
    } else if (text.includes('minute') || text.includes('hour') || text.includes('step') ||
               text.includes('preheat') || text.includes('bake') || text.includes('cook')) {
      const items = Array.from(list.querySelectorAll('li')).map(li => li.textContent.trim());
      if (instructions.length === 0 || items.length > instructions.length) {
        instructions = items;
      }
    }
  }
  
  // Try to find recipe title
  let title = document.querySelector('h1')?.textContent.trim() || document.title;
  title = title.replace(/\s*[-|]\s*.*$/, '').trim();
  
  // Find hero image
  const images = document.querySelectorAll('article img, .recipe img, .content img, main img');
  let image = null;
  for (const img of images) {
    if (img.naturalWidth > 200 && img.naturalHeight > 200) {
      image = img.src;
      break;
    }
  }
  
  return {
    title,
    image,
    ingredients: ingredients.length > 0 ? ingredients : [],
    instructions: instructions.length > 0 ? instructions : [],
    source: window.location.hostname,
    url: window.location.href
  };
}

This content script implements multiple extraction strategies, prioritizing structured data formats like JSON-LD which most modern recipe websites use. It falls back to progressively simpler methods if structured data is not available.


Background Service Worker

The background service worker handles extension lifecycle events and can manage longer-running tasks:

// Background service worker for Recipe Saver

chrome.runtime.onInstalled.addListener(() => {
  console.log('Recipe Saver extension installed');
  
  // Initialize storage with empty array
  chrome.storage.local.get(['recipes'], (result) => {
    if (!result.recipes) {
      chrome.storage.local.set({ recipes: [] });
    }
  });
});

// Handle extension icon click
chrome.action.onClicked.addListener((tab) => {
  // Open popup is default behavior, but we can add custom logic here
  console.log('Extension icon clicked on tab:', tab.url);
});

// Listen for storage changes (for syncing across contexts)
chrome.storage.onChanged.addListener((changes, area) => {
  if (area === 'local' && changes.recipes) {
    console.log('Recipes updated:', changes.recipes.newValue?.length || 0);
  }
});

Creating the Options Page

The options page provides a full-featured interface for managing all saved recipes. Create the options.html file:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Recipe Saver - My Recipes</title>
  <link rel="stylesheet" href="options.css">
</head>
<body>
  <div class="options-container">
    <header class="options-header">
      <h1>My Saved Recipes</h1>
      <div class="header-actions">
        <button id="exportBtn" class="btn btn-secondary">Export All</button>
        <button id="importBtn" class="btn btn-secondary">Import</button>
      </div>
    </header>
    
    <div class="filters">
      <input type="text" id="searchInput" placeholder="Search recipes..." class="search-input">
      <select id="sortSelect" class="sort-select">
        <option value="date">Sort by Date</option>
        <option value="title">Sort by Title</option>
        <option value="source">Sort by Source</option>
      </select>
    </div>
    
    <div id="recipeGrid" class="recipe-grid">
      <!-- Recipes will be dynamically inserted here -->
    </div>
  </div>
  
  <!-- Recipe Detail Modal -->
  <div id="recipeModal" class="modal">
    <div class="modal-content">
      <span class="modal-close">&times;</span>
      <div id="recipeDetail" class="recipe-detail">
        <!-- Recipe details will be inserted here -->
      </div>
    </div>
  </div>
  
  <input type="file" id="importFile" accept=".json" style="display: none;">
  
  <script src="options.js"></script>
</body>
</html>

The options page provides a grid view of all saved recipes with search, sorting, and filtering capabilities. It also includes export and import functionality for backup and restore.

/* Options page styles */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
  background-color: #f5f5f5;
  color: #333;
  min-height: 100vh;
}

.options-container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 24px;
}

.options-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}

.options-header h1 {
  font-size: 28px;
  color: #2c3e50;
}

.header-actions {
  display: flex;
  gap: 12px;
}

.btn {
  padding: 10px 20px;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;
}

.btn-secondary {
  background-color: #fff;
  color: #3498db;
  border: 1px solid #3498db;
}

.btn-secondary:hover {
  background-color: #3498db;
  color: #fff;
}

.btn-danger {
  background-color: #e74c3c;
  color: white;
}

.btn-danger:hover {
  background-color: #c0392b;
}

.filters {
  display: flex;
  gap: 16px;
  margin-bottom: 24px;
}

.search-input {
  flex: 1;
  padding: 12px 16px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
}

.sort-select {
  padding: 12px 16px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
  background-color: white;
  min-width: 160px;
}

.recipe-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 20px;
}

.recipe-card {
  background: white;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  cursor: pointer;
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.recipe-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}

.recipe-card-image {
  width: 100%;
  height: 180px;
  object-fit: cover;
  background-color: #e9ecef;
}

.recipe-card-content {
  padding: 16px;
}

.recipe-card-title {
  font-size: 16px;
  font-weight: 600;
  color: #2c3e50;
  margin-bottom: 8px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.recipe-card-meta {
  font-size: 13px;
  color: #6c757d;
  margin-bottom: 12px;
}

.recipe-card-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.tag {
  background-color: #e9ecef;
  color: #495057;
  padding: 4px 10px;
  border-radius: 12px;
  font-size: 12px;
}

.recipe-card-actions {
  display: flex;
  gap: 8px;
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid #e9ecef;
}

.recipe-card-actions button {
  flex: 1;
  padding: 8px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
}

.delete-btn {
  background-color: #fee;
  color: #e74c3c;
}

.delete-btn:hover {
  background-color: #fdd;
}

.modal {
  display: none;
  position: fixed;
  z-index: 1000;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0,0,0,0.5);
}

.modal-content {
  background-color: white;
  margin: 5% auto;
  padding: 24px;
  border-radius: 12px;
  width: 90%;
  max-width: 800px;
  max-height: 85vh;
  overflow-y: auto;
  position: relative;
}

.modal-close {
  position: absolute;
  right: 20px;
  top: 20px;
  font-size: 28px;
  font-weight: bold;
  color: #aaa;
  cursor: pointer;
}

.modal-close:hover {
  color: #000;
}

.recipe-detail-image {
  width: 100%;
  max-height: 400px;
  object-fit: cover;
  border-radius: 8px;
  margin-bottom: 24px;
}

.recipe-detail-title {
  font-size: 28px;
  color: #2c3e50;
  margin-bottom: 16px;
}

.recipe-detail-meta {
  display: flex;
  gap: 24px;
  margin-bottom: 24px;
  color: #6c757d;
}

.recipe-detail-section {
  margin-bottom: 24px;
}

.recipe-detail-section h3 {
  font-size: 18px;
  color: #2c3e50;
  margin-bottom: 12px;
  padding-bottom: 8px;
  border-bottom: 2px solid #27ae60;
}

.recipe-detail-section ul {
  list-style: none;
}

.recipe-detail-section li {
  padding: 8px 0;
  border-bottom: 1px solid #e9ecef;
}

.recipe-detail-section ol li {
  padding: 12px;
  margin-bottom: 8px;
  background-color: #f8f9fa;
  border-radius: 6px;
  border-left: 3px solid #27ae60;
}

.empty-state {
  text-align: center;
  padding: 60px 20px;
  color: #6c757d;
}

.empty-state-icon {
  font-size: 64px;
  margin-bottom: 16px;
}

The options page CSS provides a clean, modern interface with card-based recipe display, modal for detailed viewing, and responsive design.


Options Page JavaScript

document.addEventListener('DOMContentLoaded', () => {
  const searchInput = document.getElementById('searchInput');
  const sortSelect = document.getElementById('sortSelect');
  const recipeGrid = document.getElementById('recipeGrid');
  const recipeModal = document.getElementById('recipeModal');
  const recipeDetail = document.getElementById('recipeDetail');
  const exportBtn = document.getElementById('exportBtn');
  const importBtn = document.getElementById('importBtn');
  const importFile = document.getElementById('importFile');
  const modalClose = document.querySelector('.modal-close');

  let allRecipes = [];

  // Load recipes
  async function loadRecipes() {
    return new Promise((resolve) => {
      chrome.storage.local.get(['recipes'], (result) => {
        resolve(result.recipes || []);
      });
    });
  }

  // Save recipes
  async function saveRecipes(recipes) {
    return new Promise((resolve) => {
      chrome.storage.local.set({ recipes }, () => resolve());
    });
  }

  // Delete recipe
  async function deleteRecipe(url) {
    const recipes = allRecipes.filter(r => r.url !== url);
    await saveRecipes(recipes);
    allRecipes = recipes;
    renderRecipes(allRecipes);
  }

  // Render recipe cards
  function renderRecipes(recipes) {
    if (recipes.length === 0) {
      recipeGrid.innerHTML = `
        <div class="empty-state" style="grid-column: 1 / -1;">
          <div class="empty-state-icon">📖</div>
          <p style="font-size: 18px; margin-bottom: 8px;">No recipes saved yet</p>
          <p>Visit any recipe website and click the Recipe Saver icon to save your first recipe!</p>
        </div>
      `;
      return;
    }

    recipeGrid.innerHTML = recipes.map(recipe => `
      <div class="recipe-card" data-url="${recipe.url}">
        <img src="${recipe.image || 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22300%22 height=%22180%22%3E%3Crect fill=%22%23dee2e6%22 width=%22300%22 height=%22180%22/%3E%3Ctext x=%2250%25%22 y=%2250%25%22 dominant-baseline=%22middle%22 text-anchor=%22middle%22 fill=%22%236c757d%22 font-size=%2248%22%3E🍳%3C/text%3E%3C/svg%3E'}" 
             alt="${recipe.title}" 
             class="recipe-card-image"
             onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22300%22 height=%22180%22%3E%3Crect fill=%22%23dee2e6%22 width=%22300%22 height=%22180%22/%3E%3Ctext x=%2250%25%22 y=%2250%25%22 dominant-baseline=%22middle%22 text-anchor=%22middle%22 fill=%22%236c757d%22 font-size=%2248%22%3E🍳%3C/text%3E%3C/svg%3E'">
        <div class="recipe-card-content">
          <h3 class="recipe-card-title">${recipe.title}</h3>
          <p class="recipe-card-meta">${recipe.source || 'Unknown source'}${recipe.saveDate || ''}</p>
          ${recipe.ingredients ? `<p class="recipe-card-meta">${recipe.ingredients.length} ingredients</p>` : ''}
          <div class="recipe-card-actions">
            <button class="delete-btn" data-url="${recipe.url}">Delete</button>
          </div>
        </div>
      </div>
    `).join('');

    // Add click handlers
    document.querySelectorAll('.recipe-card').forEach(card => {
      card.addEventListener('click', (e) => {
        if (!e.target.classList.contains('delete-btn')) {
          const url = card.dataset.url;
          const recipe = allRecipes.find(r => r.url === url);
          showRecipeDetail(recipe);
        }
      });
    });

    document.querySelectorAll('.delete-btn').forEach(btn => {
      btn.addEventListener('click', async (e) => {
        e.stopPropagation();
        const url = btn.dataset.url;
        if (confirm('Are you sure you want to delete this recipe?')) {
          await deleteRecipe(url);
        }
      });
    });
  }

  // Show recipe detail in modal
  function showRecipeDetail(recipe) {
    recipeDetail.innerHTML = `
      ${recipe.image ? `<img src="${recipe.image}" alt="${recipe.title}" class="recipe-detail-image">` : ''}
      <h2 class="recipe-detail-title">${recipe.title}</h2>
      <div class="recipe-detail-meta">
        ${recipe.source ? `<span>Source: ${recipe.source}</span>` : ''}
        ${recipe.servings ? `<span>Servings: ${recipe.servings}</span>` : ''}
        ${recipe.totalTime ? `<span>Total Time: ${recipe.totalTime}</span>` : ''}
        ${recipe.prepTime ? `<span>Prep: ${recipe.prepTime}</span>` : ''}
        ${recipe.cookTime ? `<span>Cook: ${recipe.cookTime}</span>` : ''}
      </div>
      ${recipe.description ? `<p style="margin-bottom: 24px; color: #6c757d;">${recipe.description}</p>` : ''}
      ${recipe.ingredients && recipe.ingredients.length > 0 ? `
        <div class="recipe-detail-section">
          <h3>Ingredients</h3>
          <ul>
            ${recipe.ingredients.map(ing => `<li>${ing}</li>`).join('')}
          </ul>
        </div>
      ` : ''}
      ${recipe.instructions && recipe.instructions.length > 0 ? `
        <div class="recipe-detail-section">
          <h3>Instructions</h3>
          <ol>
            ${recipe.instructions.map(inst => `<li>${inst}</li>`).join('')}
          </ol>
        </div>
      ` : ''}
      <div style="margin-top: 24px;">
        <a href="${recipe.url}" target="_blank" class="btn btn-secondary">View Original Recipe</a>
      </div>
    `;
    
    recipeModal.style.display = 'block';
  }

  // Close modal
  modalClose.addEventListener('click', () => {
    recipeModal.style.display = 'none';
  });

  window.addEventListener('click', (e) => {
    if (e.target === recipeModal) {
      recipeModal.style.display = 'none';
    }
  });

  // Search functionality
  searchInput.addEventListener('input', (e) => {
    const query = e.target.value.toLowerCase();
    const filtered = filterRecipes(query);
    renderRecipes(sortRecipes(filtered));
  });

  // Sort functionality
  sortSelect.addEventListener('change', () => {
    const filtered = filterRecipes(searchInput.value.toLowerCase());
    renderRecipes(sortRecipes(filtered));
  });

  // Filter recipes
  function filterRecipes(query) {
    if (!query) return allRecipes;
    return allRecipes.filter(recipe => 
      recipe.title.toLowerCase().includes(query) ||
      (recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(query))) ||
      (recipe.source && recipe.source.toLowerCase().includes(query))
    );
  }

  // Sort recipes
  function sortRecipes(recipes) {
    const sortBy = sortSelect.value;
    return [...recipes].sort((a, b) => {
      if (sortBy === 'title') return a.title.localeCompare(b.title);
      if (sortBy === 'source') return (a.source || '').localeCompare(b.source || '');
      return 0; // date - most recent first
    });
  }

  // Export recipes
  exportBtn.addEventListener('click', () => {
    const dataStr = JSON.stringify(allRecipes, null, 2);
    const blob = new Blob([dataStr], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `recipe-saver-export-${new Date().toISOString().split('T')[0]}.json`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  });

  // Import recipes
  importBtn.addEventListener('click', () => {
    importFile.click();
  });

  importFile.addEventListener('change', async (e) => {
    const file = e.target.files[0];
    if (!file) return;
    
    try {
      const text = await file.text();
      const imported = JSON.parse(text);
      
      if (Array.isArray(imported)) {
        // Merge with existing recipes
        const existingUrls = new Set(allRecipes.map(r => r.url));
        const newRecipes = imported.filter(r => !existingUrls.has(r.url));
        allRecipes = [...allRecipes, ...newRecipes];
        await saveRecipes(allRecipes);
        renderRecipes(allRecipes);
        alert(`Imported ${newRecipes.length} new recipes!`);
      }
    } catch (error) {
      alert('Error importing recipes: ' + error.message);
    }
    
    importFile.value = '';
  });

  // Check if opened from popup with view=all
  const urlParams = new URLSearchParams(window.location.search);
  if (urlParams.get('view') === 'all') {
    sortSelect.value = 'date';
  }

  // Initialize
  loadRecipes().then(recipes => {
    allRecipes = recipes;
    renderRecipes(allRecipes);
  });
});

Testing Your Recipe Saver Extension

Before publishing your extension, thorough testing is essential. Load your extension in Chrome by following these steps:

  1. Open Chrome and navigate to chrome://extensions/
  2. Enable “Developer mode” using the toggle in the top right corner
  3. Click “Load unpacked” and select your extension’s folder
  4. The extension icon should appear in your toolbar

Test the extension on various recipe websites to ensure the extraction works correctly. Try these popular recipe sites:

  • Allrecipes.com
  • Bon Appetit
  • Food Network
  • Serious Eats
  • Local recipe blogs

The JSON-LD extraction should work well on major sites, while the fallback strategies handle smaller blogs.


Publishing to the Chrome Web Store

Once your extension is tested and working, you can publish it to the Chrome Web Store:

  1. Create a developer account at the Chrome Web Store Developer Dashboard
  2. Zip your extension folder (exclude .git and unnecessary files)
  3. Upload the zip file and fill in the store listing details
  4. Submit for review

Your store listing should include:

  • A clear, descriptive title
  • Detailed description with feature list
  • Screenshots showing the extension in action
  • Appropriate category selection

Future Enhancements

After launching your basic recipe saver extension, consider adding these advanced features:

  • Cloud Sync: Use Firebase or a similar service to sync recipes across devices
  • Meal Planning: Add a weekly meal planning calendar
  • Shopping List: Generate shopping lists from selected recipes
  • Social Sharing: Allow users to share recipes with friends
  • Cooking Mode: Full-screen step-by-step cooking instructions
  • Recipe Scaling: Automatically scale ingredient quantities
  • Nutritional Information: Integrate with nutrition APIs
  • Pocket/Instapaper Integration: Save recipes to other services

Conclusion

Building a recipe saver Chrome extension is an excellent project that combines practical utility with interesting technical challenges. The extension you create can genuinely help users organize their cooking resources and discover new recipes.

This guide covered the essential aspects of extension development including project setup, manifest configuration, popup and options page creation, content script recipe extraction, and storage management. With these fundamentals, you have a solid foundation to build upon and customize according to your vision.

The recipe saver extension demonstrates several key Chrome extension development concepts: content script injection for page interaction, message passing between contexts, local storage for data persistence, and multi-component architecture. These skills transfer directly to other extension projects you might undertake.

Start building your recipe saver today, and soon you’ll have a valuable tool for yourself and potentially thousands of users who want to save recipes chrome-wide with a single click.

No previous article
No next article