Mealime closes on October 21

Save your Mealime recipes

Mealime has no export button, so we made a free one. Drag the Save my Mealime data button below to your bookmarks bar, sign in at my.mealime.com on a computer, then click the button: a mealime-backup file lands in your Downloads folder in a minute or two. It saves the recipes you typed in yourself plus your favorites in full, with ingredients, steps, cook times, and nutrition. It never asks for your Mealime password, and it is free whichever app you use next.

  1. Drag this button to your bookmarks bar

    Save my Mealime data

    No bookmarks bar? Press Ctrl+Shift+B on Windows or+Shift+B on a Mac.

  2. Sign in at my.mealime.com

    On a computer, in Chrome, Safari, Edge, or Firefox.

  3. Click “Save my Mealime data” in your bookmarks bar

    A box shows its progress, then a mealime-backup file lands in your Downloads folder.

  • We never ask for your Mealime password
  • Nothing is sent to us
  • It only reads, never changes anything

More details

What goes into the file?
  • Recipes you typed in yourself, with ingredients and steps.
  • Your favorites from Mealime’s own recipes, in full: ingredients, steps, cook time, and nutrition. These are the part to save now, since they almost certainly go offline on October 21.
  • Your notes, ratings, and recent meal plans.
  • The web address of each photo (see below).

Not included: Mealime recipes you cooked but never favorited. Favorite any you want to keep before you save.

What about photos?

Mealime’s photo server won’t let a program in your browser copy photos, so the file keeps each photo’s web address instead. Those work only while Mealime is running, so import the file into an app that fetches them (MealBright does) before October 21. After that, your recipes still import in full, just without Mealime’s photos.

Is it safe? Can I see the code?

The button runs inside your own browser while you’re signed in to Mealime. It only talks to Mealime, only reads, and leaves your sign-in out of the file, so the file can’t be used to get into your account. It’s these two short files, bundled together:

// The "Save my Mealime data" button (phase-2-plan step 1).
//
// This runs as a bookmarklet: the person drags it to their bookmarks bar, signs in at
// my.mealime.com, and clicks it. It then, all inside their own browser:
//   1. asks Mealime for their account (the same request Mealime's own site makes),
//   2. downloads each favorite's full recipe from Mealime's public recipe files,
//   3. removes the sign-in and share tokens, and offers one JSON file to download.
// Nothing is sent to MealBright. It never calls a Mealime action that changes anything.
//
// The site's build bundles this file (and shared/mealime/backup.ts) into the bookmark's link;
// see scripts/build-bookmarklet.mjs and site/src/pages/save-mealime-recipes.astro.
import {
  backupFileName,
  buildBackup,
  favoriteRecipeUrl,
  favoriteUuids,
  type MealimeFavoriteFailure,
} from '../../../shared/mealime/backup';

const SAVED_BY = 'MealBright Save button v1';
const MEALIME_HOST = 'my.mealime.com';
/**
 * Favorites download three at a time: gentle on Mealime's servers, and quick (each takes about
 * 0.2 s). There are deliberately no timed pauses between them, because browsers slow timers
 * down to a second or more when the tab is in the background (seen in testing 2026-09-18).
 */
const CONCURRENT_DOWNLOADS = 3;
const RETRIES = 2;
/** Marks the page while a save is running, so a double click doesn't start a second one. */
const RUNNING_FLAG = '__mealbrightSaveRunning';

type Win = Window & { [RUNNING_FLAG]?: boolean };

interface PanelButton {
  label: string;
  onClick: () => void;
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/** A small box in the corner of Mealime's page that shows what's happening. */
function createPanel() {
  const box = document.createElement('div');
  box.setAttribute('role', 'status');
  box.setAttribute('aria-live', 'polite');
  Object.assign(box.style, {
    position: 'fixed',
    right: '16px',
    bottom: '16px',
    zIndex: '2147483647',
    maxWidth: '340px',
    padding: '16px 20px',
    borderRadius: '16px',
    background: '#161B1D',
    color: '#FFFFFF',
    font: '15px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
    boxShadow: '0 12px 32px rgba(0,0,0,.3)',
  });
  const title = document.createElement('strong');
  title.textContent = 'MealBright™: saving your Mealime data';
  Object.assign(title.style, { display: 'block', marginBottom: '6px', color: '#BCE076' });
  const message = document.createElement('div');
  const actions = document.createElement('div');
  Object.assign(actions.style, { display: 'flex', flexWrap: 'wrap', gap: '8px' });
  box.append(title, message, actions);
  document.body.append(box);

  // The first button is the main one (lime); any others are quieter.
  const makeButton = ({ label, onClick }: PanelButton, primary: boolean) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = label;
    Object.assign(button.style, {
      minHeight: '44px',
      marginTop: '12px',
      padding: '0 20px',
      border: '0',
      borderRadius: '999px',
      background: primary ? '#8ED63E' : '#2A3236',
      color: primary ? '#14210A' : '#FFFFFF',
      font: 'inherit',
      fontWeight: '600',
      cursor: 'pointer',
    });
    button.addEventListener('click', onClick);
    return button;
  };

  return {
    say(text: string) {
      message.textContent = text;
    },
    /** Replaces the heading, text, and buttons. The first button gets keyboard focus. */
    show(heading: string, text: string, buttons: PanelButton[]) {
      title.textContent = heading;
      message.textContent = text;
      const made = buttons.map((b, i) => makeButton(b, i === 0));
      actions.replaceChildren(...made);
      made[0]?.focus();
    },
    close() {
      box.remove();
    },
  };
}

/** Mealime's read-only account request, made with the person's own signed-in session. */
async function fetchAccount(token: string): Promise<Record<string, unknown>> {
  const response = await fetch('https://api.mealime.com/api/v2/get_user', {
    method: 'POST',
    headers: { Authorization: `Token token=${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ source: 'my-web' }),
  });
  if (!response.ok) throw new Error(`account ${response.status}`);
  const data: unknown = await response.json();
  if (!data || typeof data !== 'object') throw new Error('account shape');
  return data as Record<string, unknown>;
}

/** One favorite's public recipe file, retried a couple of times if the network hiccups. */
async function fetchFavorite(
  uuid: string,
): Promise<{ ok: true; data: unknown } | { ok: false; status: number }> {
  let status = 0;
  for (let attempt = 0; attempt <= RETRIES; attempt += 1) {
    if (attempt > 0) await sleep(1000 * attempt);
    try {
      const response = await fetch(favoriteRecipeUrl(uuid));
      status = response.status;
      if (response.ok) return { ok: true, data: await response.json() };
      // A missing recipe won't appear by asking again.
      if (response.status === 403 || response.status === 404) break;
    } catch {
      status = 0;
    }
  }
  return { ok: false, status };
}

/**
 * Hands the file to the browser, which saves it to the Downloads folder. Only ever called from
 * a click on a button: browsers quietly block downloads a page starts on its own after the
 * first one (Chrome, seen in testing 2026-09-18) or after a long wait (Safari).
 */
function download(fileName: string, contents: string) {
  const url = URL.createObjectURL(new Blob([contents], { type: 'application/json' }));
  const link = document.createElement('a');
  link.href = url;
  link.download = fileName;
  document.body.append(link);
  link.click();
  link.remove();
  setTimeout(() => URL.revokeObjectURL(url), 60_000);
}

async function run() {
  if (location.hostname !== MEALIME_HOST) {
    alert(
      'To save your Mealime recipes, go to my.mealime.com and sign in, then click “Save my Mealime data” again.',
    );
    return;
  }
  const win = window as Win;
  if (win[RUNNING_FLAG]) return;

  // Mealime keeps the signed-in person's token here (mealime-format.md). It is only sent to
  // Mealime's own server, and never written into the file.
  let token: string | null;
  try {
    token = localStorage.getItem('mealimeAuthToken');
  } catch {
    token = null;
  }
  if (!token) {
    alert('Please sign in to Mealime first, then click “Save my Mealime data” again.');
    return;
  }

  win[RUNNING_FLAG] = true;
  const panel = createPanel();
  const close: PanelButton = { label: 'Close', onClick: () => panel.close() };
  try {
    panel.say('Reading your account…');
    const account = await fetchAccount(token);

    const uuids = favoriteUuids(account);
    const favorites: Record<string, unknown> = {};
    const favoriteFailures: MealimeFavoriteFailure[] = [];
    let next = 0;
    let finished = 0;
    // Each worker takes the next favorite off the list until none are left.
    const worker = async () => {
      while (next < uuids.length) {
        const uuid = uuids[next++] as string;
        const result = await fetchFavorite(uuid);
        if (result.ok) favorites[uuid] = result.data;
        else favoriteFailures.push({ uuid, status: result.status });
        finished += 1;
        panel.say(`Saving your favorites: ${finished} of ${uuids.length}…`);
      }
    };
    if (uuids.length) panel.say(`Saving your favorites: 0 of ${uuids.length}…`);
    await Promise.all(Array.from({ length: CONCURRENT_DOWNLOADS }, worker));

    const now = new Date();
    const backup = buildBackup({
      account,
      favorites,
      favoriteFailures,
      savedAt: now,
      savedBy: SAVED_BY,
    });
    const fileName = backupFileName(now);
    const contents = JSON.stringify(backup);

    // Recipes the person deleted in Mealime are saved too, but aren't counted in the message.
    const ownCount = Array.isArray(account.user_recipes)
      ? account.user_recipes.filter((r) => !(r as { is_deleted?: unknown } | null)?.is_deleted)
          .length
      : 0;
    const saved = uuids.length - favoriteFailures.length;
    const missed = favoriteFailures.length
      ? ` ${favoriteFailures.length} favorite${favoriteFailures.length === 1 ? '' : 's'} couldn’t be downloaded; run the Save button again later to pick them up.`
      : '';
    const downloadAgain: PanelButton = {
      label: 'Download again',
      onClick: () => download(fileName, contents),
    };

    panel.show(
      'Your file is ready',
      `It has ${ownCount} of your own recipes and ${saved} favorites.${missed}`,
      [
        {
          label: 'Download my file',
          onClick: () => {
            download(fileName, contents);
            panel.show(
              'Your Mealime data is saved',
              `Look for ${fileName} in your Downloads folder, and keep it somewhere safe. Photos aren’t in the file: import it into MealBright before October 21 to keep them.`,
              [close, downloadAgain],
            );
          },
        },
        close,
      ],
    );
  } catch {
    panel.show(
      'Something went wrong',
      'Your Mealime data couldn’t be saved. Make sure you’re signed in to Mealime, reload the page, and try again. Nothing in your Mealime account was changed.',
      [close],
    );
  } finally {
    win[RUNNING_FLAG] = false;
  }
}

void run();
// The "Save my Mealime data" backup file (phase-2-plan step 1, docs/product/mealime-format.md).
//
// The Save button (site/src/save-button/) builds this file in the person's own browser while
// they're signed in to Mealime. MealBright's importer reads it later, even after Mealime shuts
// down on 2026-10-21. Everything here is plain data work with no browser or Node APIs, so both
// sides share it and the tests can check it.

/** Written into every file so the importer knows what it's reading. */
export const MEALIME_BACKUP_FORMAT = 'mealbright-mealime-backup';
export const MEALIME_BACKUP_VERSION = 1;

/**
 * Account fields left out of the file. The sign-in and share tokens would let anyone holding
 * the file act as the person on Mealime (mealime-format.md: "the saved account file must have
 * auth_token and share_token removed"). Store receipts, subscription details, and tracking ids
 * are payment and analytics records that no importer needs.
 */
export const STRIPPED_ACCOUNT_FIELDS = [
  'auth_token',
  'share_token',
  'subscription',
  'ios_subscription',
  'android_subscription',
  'mock_subscription',
  'organization_subscription',
  'ios_receipt_check',
  'tracking_id',
] as const;

export type MealimeAccountData = Record<string, unknown>;

export interface MealimeFavoriteFailure {
  uuid: string;
  /** HTTP status, or 0 when the request never got an answer. */
  status: number;
}

export interface MealimeBackup {
  format: typeof MEALIME_BACKUP_FORMAT;
  version: typeof MEALIME_BACKUP_VERSION;
  /** When the file was made (ISO 8601). */
  savedAt: string;
  /** Which Save button made it, for troubleshooting. */
  savedBy: string;
  /** What `get_user` returned, minus STRIPPED_ACCOUNT_FIELDS. */
  account: MealimeAccountData;
  /** Each favorite's full recipe file, keyed by its published_recipe_uuid. */
  favorites: Record<string, unknown>;
  /** Favorites that couldn't be downloaded, so the importer can say which are missing. */
  favoriteFailures: MealimeFavoriteFailure[];
  /**
   * Photos aren't inside the file: Mealime's photo server doesn't let a browser program read
   * images (checked 2026-09-18). The recipes keep each photo's web address, which MealBright's
   * server can fetch until Mealime shuts down.
   */
  photos: 'addresses-only';
}

/** A copy of the account with the sensitive fields removed. The original is left untouched. */
export function cleanAccount(account: MealimeAccountData): MealimeAccountData {
  const copy: MealimeAccountData = { ...account };
  for (const field of STRIPPED_ACCOUNT_FIELDS) delete copy[field];
  return copy;
}

/**
 * The favorites to download: each distinct published_recipe_uuid, in the account's order.
 * Favorites without one can't be fetched (mealime-format.md, "Known gaps") and are skipped.
 */
export function favoriteUuids(account: MealimeAccountData): string[] {
  const list = Array.isArray(account.favourites) ? account.favourites : [];
  const seen = new Set<string>();
  for (const item of list) {
    const uuid = (item as { published_recipe_uuid?: unknown } | null)?.published_recipe_uuid;
    // Only UUID-shaped values go into a web address, so nothing odd can change the request.
    if (typeof uuid === 'string' && /^[0-9a-f-]{32,40}$/i.test(uuid)) seen.add(uuid);
  }
  return [...seen];
}

/** The web address of one favorite's public recipe file. */
export function favoriteRecipeUrl(uuid: string): string {
  return `https://cdn-recipes.mealime.com/${encodeURIComponent(uuid)}.json`;
}

export function buildBackup(input: {
  account: MealimeAccountData;
  favorites: Record<string, unknown>;
  favoriteFailures: MealimeFavoriteFailure[];
  savedAt: Date;
  savedBy: string;
}): MealimeBackup {
  return {
    format: MEALIME_BACKUP_FORMAT,
    version: MEALIME_BACKUP_VERSION,
    savedAt: input.savedAt.toISOString(),
    savedBy: input.savedBy,
    account: cleanAccount(input.account),
    favorites: input.favorites,
    favoriteFailures: input.favoriteFailures,
    photos: 'addresses-only',
  };
}

/** For example "mealime-backup-2026-09-18.json", using the person's local date. */
export function backupFileName(date: Date): string {
  const pad = (n: number) => String(n).padStart(2, '0');
  return `mealime-backup-${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}.json`;
}
Can I do this on my phone?

It works best on a computer, where the button can sit in your bookmarks bar. If you deleted the Mealime app, that’s fine: your recipes are kept with your account, so sign in at my.mealime.com on a computer before October 21.

Some favorites didn’t download. What now?

The box says how many were missed. Run the button again later; each run saves a fresh, complete file.

Is this from Mealime?

No. MealBright is an independent app and isn’t affiliated with Mealime. We made the button because people should be able to keep their recipes.