Build a Discord bot to expose Raindrop.io instances



This content originally appeared on DEV Community and was authored by Greg Holmes

I’m on quite a few different Discord servers, and one of them has a 3D printing channel with a catalog of STLs. Initially, it was just a bunch of posts in a specific channel that got pinned. The problem? Those pinned posts were constantly outdated and needed manual updates from moderators or whoever originally posted them. Pretty tedious and annoying to keep bugging them.

One of the community members had a brilliant idea: why not use Raindrop.io? We could make it a collaborative catalog that willing contributors could help maintain. It worked great! The catalog grew into something really useful… but there was one catch. Every time someone wanted to search for an STL file, they had to leave Discord and open Raindrop in their browser.

So I thought, “Why not bring the search functionality directly into Discord?” That way, people can find what they need without ever leaving the conversation. You type a command, get your results privately in the same channel, and you’re good to go.

What We’re Building

In this tutorial, I’ll walk you through building a system that backs up your Raindrop.io contents to a Supabase database and makes them searchable via a Discord bot. This project consists of two main components:

  1. A TypeScript cron job that syncs Raindrop.io collections and bookmarks to Supabase.
  2. A Discord bot that allows you to search your bookmarks by tags using slash commands.

Whether you’re managing a 3D printing catalog like me, organizing documentation links, or even if you just want to build a Discord bot, this tutorial will show you exactly how to build it.

Project Overview

The system architecture includes:

  • Raindrop.io API – Source of bookmark data
  • Supabase – PostgreSQL database for storing bookmarks
  • Discord Bot – Interface for searching bookmarks
  • TypeScript – Type-safe implementation

Prerequisites

Before starting, make sure you have:

  • Node.js (v20 or higher) installed
  • A Raindrop.io account and API credentials
  • A Supabase account, database with:
    • Supabase URL,
    • Supabase anon key,
    • Supabase service role key.
  • A Discord bot token and application
  • Basic knowledge of TypeScript and SQL

Step 1: Project Initialization

Create a new project directory and initialize it:

mkdir raindrop-backup-bot
cd raindrop-backup-bot
npm init -y

Step 2: Install Dependencies

Install all required packages. These provide:

  • @supabase/supabase-js – Official Supabase client for interacting with your PostgreSQL database
  • axios – HTTP client for making API requests to Raindrop.io
  • discord.js – Complete toolkit for building Discord bots and handling bot interactions
  • dotenv – Loads environment variables from .env file for secure credential management
npm install @supabase/supabase-js axios discord.js dotenv

Install development dependencies. These provide:

  • @types/node – TypeScript type definitions for Node.js built-in modules
  • typescript – TypeScript compiler for type-safe development
  • ts-node – Executes TypeScript files directly without pre-compilation
npm install --save-dev @types/node typescript ts-node

Step 3: TypeScript Configuration

Create a tsconfig.json file in your project root:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

This configuration ensures proper TypeScript compilation with ES2020 features and strict type checking.

Step 4: Project Structure

Create the following directories and files:

mkdir -p src supabase/migrations
touch .env

Your project should look like this:

raindrop-backup-bot/
├── src/
├── supabase/
│   └── migrations/
├── .env
├── package.json
└── tsconfig.json

Step 5: Environment Configuration

Create a .env file with all required environment variables:

RAINDROP_REFRESH_TOKEN=
RAINDROP_CLIENT_ID=
RAINDROP_CLIENT_SECRET=

SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=

DISCORD_TOKEN=
DISCORD_CHANNEL_ID=
DISCORD_CLIENT_ID=

Getting Raindrop.io Credentials

  1. Go to Raindrop.io App Settings
  2. Create a new app to get your client_id and client_secret
  3. Follow the OAuth flow to get your refresh_token

Getting Supabase Credentials

  1. Create a new project at Supabase
  2. Go to Project Settings > API
  3. Copy your project URL and service role key

Getting Discord Bot Credentials

  1. Go to Discord Developer Portal
  2. Create a new application
  3. Go to Bot section and create a bot
  4. Copy the bot token
  5. Copy the application ID (client ID)

Step 6: Database Schema

Create the database schema file with the command supabase migration new create_tables and populate the contents with the following:

-- Create collections table
CREATE TABLE collections (
    id BIGSERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    description TEXT,
    parent_id BIGINT REFERENCES collections(id) ON DELETE SET NULL,
    count INTEGER DEFAULT 0,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    slug TEXT
);

-- Create raindrops table
CREATE TABLE raindrops (
    id BIGSERIAL PRIMARY KEY,
    link TEXT NOT NULL,
    title TEXT NOT NULL,
    type TEXT,
    cover TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    collection_id BIGINT REFERENCES collections(id) ON DELETE CASCADE
);

-- Create tags table
CREATE TABLE tags (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Create media table
CREATE TABLE media (
    id BIGSERIAL PRIMARY KEY,
    link TEXT NOT NULL,
    type TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    raindrop_id BIGINT REFERENCES raindrops(id) ON DELETE CASCADE
);

-- Create many-to-many relationship between raindrops and tags
CREATE TABLE raindrop_tags (
    raindrop_id BIGINT REFERENCES raindrops(id) ON DELETE CASCADE,
    tag_id BIGINT REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (raindrop_id, tag_id)
);

Database Schema Explanation

The schema consists of five tables:

  • collections: Stores Raindrop.io collections (folders) with hierarchical support via parent_id
  • raindrops: Stores individual bookmarks with links, titles, and references to collections
  • tags: Stores unique tags used to categorize bookmarks
  • media: Stores media items (images, videos) associated with bookmarks
  • raindrop_tags: Junction table for many-to-many relationship between raindrops and tags

Run this migration in your Supabase SQL editor or using the Supabase CLI with supabase db push.

Step 7: Backup Script – Part 1 (Setup and OAuth)

Create src/backupRaindrop.ts. Start with the imports and setup:

import axios from 'axios';
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';

dotenv.config();

const clientId = process.env.RAINDROP_CLIENT_ID;
const clientSecret = process.env.RAINDROP_CLIENT_SECRET;
const refreshToken = process.env.RAINDROP_REFRESH_TOKEN;

const supabaseUrl = process.env.SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const supabase = createClient(supabaseUrl, supabaseKey);

if (!clientId || !clientSecret || !refreshToken) {
  throw new Error('Missing required environment variables');
}

// OAuth token endpoint
const TOKEN_URL = 'https://raindrop.io/oauth/access_token';
const API_BASE = 'https://api.raindrop.io/rest/v1';

OAuth Token Refresh Function

Add the function to refresh your Raindrop.io access token:

export async function refreshAccessToken(): Promise<string> {
  const response = await axios.post(TOKEN_URL, {
    grant_type: 'refresh_token',
    refresh_token: refreshToken,
    client_id: clientId,
    client_secret: clientSecret,
  });

  return response.data.access_token;
}

This function uses the OAuth refresh token flow to get a new access token for API calls.

Step 8: Backup Script – Part 2 (Collections Sync)

Add functions to fetch and sync collections:

// Function to fetch collections from Raindrop.io
async function fetchRaindropCollections() {
  const accessToken = await refreshAccessToken();
  const response = await axios.get(`${API_BASE}/collections/childrens`, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });

  if (!response.data.result) {
    throw new Error('Failed to fetch collections from Raindrop.io');
  }

  return response.data.items;
}

// Function to separate collections into childCollections and parentCollections
function separateCollections(collections: any[]) {
  const childCollections: any[] = [];
  const parentCollections: any[] = [];

  for (const collection of collections) {
    if (collection.parent?.$id) {
      parentCollections.push(collection);
    } else {
      childCollections.push(collection);
    }
  }

  return { childCollections, parentCollections };
}

// Function to check if a collection exists in Supabase
async function collectionExists(id: number): Promise<boolean> {
  const { data, error } = await supabase
    .from('collections')
    .select('id')
    .eq('id', id)
    .single();

  if (error && error.code !== 'PGRST116') {
    console.error('Error checking collection existence:', error.message);
    throw error;
  }

  return !!data;
}

Insert and Update Collection Functions

These functions handle inserting collections into Supabase and updating their parent-child relationships after all collections are stored.

// Function to insert a collection into Supabase with parent_id set to NULL
async function insertCollectionWithNullParent(collection: any) {
  const { _id, title, description, count, slug } = collection;

  const { error } = await supabase.from('collections').insert({
    id: _id,
    title,
    description,
    parent_id: null,
    count,
    slug,
  });

  if (error) {
    console.error(`Error inserting collection "${title}":`, error.message);
    throw error;
  }

  console.log(`Inserted collection with NULL parent: ${title}`);
}

// Function to update the parent_id for a collection
async function updateCollectionParent(collection: any) {
  const { _id, parent } = collection;

  if (parent?.$id) {
    const { error } = await supabase
      .from('collections')
      .update({ parent_id: parent.$id })
      .eq('id', _id);

    if (error) {
      console.error(`Error updating parent_id for collection "${_id}":`, error.message);
      throw error;
    }

    console.log(`Updated parent_id for collection: ${_id}`);
  }
}

Collections Sync Main Function

This function orchestrates the entire collection sync process by fetching collections from Raindrop.io, separating them by hierarchy, and inserting them with proper relationships.

// Main function to sync collections
async function syncCollections() {
  try {
    const collections = await fetchRaindropCollections();

    // Separate collections into childCollections and parentCollections
    const { childCollections, parentCollections } = separateCollections(collections);

    console.log('Inserting Parent Collections with NULL parent_id...');
    for (const collection of parentCollections) {
      const exists = await collectionExists(collection._id);
      if (!exists) {
        await insertCollectionWithNullParent(collection);
      } else {
        console.log(`Parent collection already exists: ${collection.title}`);
      }
    }

    console.log('Inserting Child Collections with NULL parent_id...');
    for (const collection of childCollections) {
      const exists = await collectionExists(collection._id);
      if (!exists) {
        await insertCollectionWithNullParent(collection);
      } else {
        console.log(`Child collection already exists: ${collection.title}`);
      }
    }

    console.log('Updating Parent IDs for All Collections...');
    for (const collection of [...parentCollections, ...childCollections]) {
      await updateCollectionParent(collection);
    }

    console.log('Collections sync completed.');
  } catch (error) {
    if (error instanceof Error) {
      console.error('Error syncing collections:', error.message);
    } else {
      console.error('Error syncing collections:', error);
    }
  }
}

Step 9: Backup Script – Part 3 (Raindrops Sync)

Add functions to sync individual raindrops (bookmarks):

// Function to fetch raindrops for a specific collection
async function fetchRaindropsByCollection(collectionId: number, accessToken: string) {
  const response = await axios.get(`${API_BASE}/raindrops/${collectionId}`, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });

  if (!response.data.result) {
    throw new Error(`Failed to fetch raindrops for collection ID: ${collectionId}`);
  }

  return response.data.items;
}

// Function to check if a raindrop exists in Supabase
async function raindropExists(id: number): Promise<boolean> {
  const { data, error } = await supabase
    .from('raindrops')
    .select('id')
    .eq('id', id)
    .single();

  if (error && error.code !== 'PGRST116') {
    console.error('Error checking raindrop existence:', error.message);
    throw error;
  }

  return !!data;
}

// Function to insert a raindrop into Supabase
async function insertRaindrop(raindrop: any, collectionId: number) {
  const { _id, link, title, type, cover, created } = raindrop;

  const { error } = await supabase.from('raindrops').insert({
    id: _id,
    link,
    title,
    type,
    cover,
    created_at: created,
    updated_at: new Date().toISOString(),
    collection_id: collectionId,
  });

  if (error) {
    console.error(`Error inserting raindrop "${title}":`, error.message);
    throw error;
  }

  console.log(`Inserted raindrop: ${title}`);
}

Step 10: Backup Script – Part 4 (Media and Tags)

Add functions to handle media and tags:

// Function to insert media entries for a raindrop
async function insertMediaEntries(media: any[], raindropId: number) {
  for (const entry of media) {
    const { link, type } = entry;

    const { error } = await supabase.from('media').insert({
      link,
      type,
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
      raindrop_id: raindropId,
    });

    if (error) {
      console.error(`Error inserting media for raindrop ID "${raindropId}":`, error.message);
      throw error;
    }

    console.log(`Inserted media for raindrop ID: ${raindropId}`);
  }
}

// Function to check if a tag exists in Supabase
async function tagExists(name: string): Promise<number | null> {
  const { data, error } = await supabase
    .from('tags')
    .select('id')
    .eq('name', name)
    .single();

  if (error && error.code !== 'PGRST116') {
    console.error(`Error checking tag existence for "${name}":`, error.message);
    throw error;
  }

  return data ? data.id : null;
}

// Function to insert a tag into Supabase
async function insertTag(name: string): Promise<number> {
  const { data, error } = await supabase
    .from('tags')
    .insert({
      name,
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    })
    .select('id')
    .single();

  if (error) {
    console.error(`Error inserting tag "${name}":`, error.message);
    throw error;
  }

  console.log(`Inserted tag: ${name}`);
  return data.id;
}

// Function to insert a raindrop-tag relationship
async function insertRaindropTag(raindropId: number, tagId: number) {
  const { error } = await supabase.from('raindrop_tags').insert({
    raindrop_id: raindropId,
    tag_id: tagId,
  });

  if (error) {
    console.error(`Error inserting raindrop-tag relationship for raindrop ID "${raindropId}" and tag ID "${tagId}":`, error.message);
    throw error;
  }

  console.log(`Inserted raindrop-tag relationship: raindrop ID ${raindropId}, tag ID ${tagId}`);
}

// Function to process tags for a raindrop
async function processTags(tags: string[], raindropId: number) {
  for (const tag of tags) {
    let tagId = await tagExists(tag);

    if (!tagId) {
      tagId = await insertTag(tag);
    }

    await insertRaindropTag(raindropId, tagId);
  }
}

Step 11: Backup Script – Part 5 (Raindrops Sync Main Function)

Add the main function to sync all raindrops:

// Main function to sync raindrops
async function syncRaindrops() {
  try {
    const accessToken = await refreshAccessToken();

    // Fetch all collections from Supabase
    const { data: collections, error: collectionsError } = await supabase
      .from('collections')
      .select('id');

    if (collectionsError) {
      console.error('Error fetching collections:', collectionsError.message);
      throw collectionsError;
    }

    if (!collections || collections.length === 0) {
      console.log('No collections found to sync raindrops.');
      return;
    }

    console.log('Syncing raindrops for collections...');
    for (const collection of collections) {
      const collectionId = collection.id;

      console.log(`Fetching raindrops for collection ID: ${collectionId}`);
      const raindrops = await fetchRaindropsByCollection(collectionId, accessToken);

      for (const raindrop of raindrops) {
        const exists = await raindropExists(raindrop._id);
        if (!exists) {
          await insertRaindrop(raindrop, collectionId);

          // Insert media entries for the raindrop
          if (raindrop.media && raindrop.media.length > 0) {
            await insertMediaEntries(raindrop.media, raindrop._id);
          }

          // Process tags for the raindrop
          if (raindrop.tags && raindrop.tags.length > 0) {
            await processTags(raindrop.tags, raindrop._id);
          }
        } else {
          console.log(`Raindrop already exists: ${raindrop.title}`);
        }
      }
    }

    console.log('Raindrops sync completed.');
  } catch (error) {
    if (error instanceof Error) {
      console.error('Error syncing raindrops:', error.message);
    } else {
      console.error('Error syncing raindrops:', error);
    }
  }
}

Step 12: Backup Script – Part 6 (Execute)

Add the execution calls at the end of src/backupRaindrop.ts:

// Run the sync process
syncCollections();

// Run the sync process
syncRaindrops();

This completes the backup script. The script will:

  1. Fetch all collections from Raindrop.io
  2. Insert them into Supabase with proper parent-child relationships
  3. Fetch all raindrops (bookmarks) from each collection
  4. Insert raindrops along with their media and tags

Step 13: Discord Bot – Part 1 (Setup)

Create src/discordBot.ts and start with the imports and setup:

import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from 'discord.js';
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';

dotenv.config();

// Discord bot token from environment variables
const DISCORD_TOKEN = process.env.DISCORD_TOKEN;
const CLIENT_ID = process.env.DISCORD_CLIENT_ID;

if (!DISCORD_TOKEN) {
  throw new Error('DISCORD_TOKEN is not defined in environment variables.');
}

// Create a new Discord client
const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent
  ],
});

const supabaseUrl = process.env.SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const supabase = createClient(supabaseUrl, supabaseKey);

// Event: Bot is ready
client.once('ready', () => {
  console.log(`Logged in as ${client.user?.tag}!`);
});

Step 14: Discord Bot – Part 2 (Slash Command Registration)

Register the /find slash command with Discord’s API so users can search for bookmarks by tag:

// Register the /find command
const commands = [
  new SlashCommandBuilder()
    .setName('find')
    .setDescription('Search for raindrops by tag')
    .addStringOption((option) =>
      option.setName('tag').setDescription('The tag to search for').setRequired(true)
    ),
];

const rest = new REST({ version: '10' }).setToken(DISCORD_TOKEN);

(async () => {
  try {
    console.log('Started refreshing application (/) commands.');

    if (!CLIENT_ID) {
      throw new Error('CLIENT_ID is not defined in environment variables.');
    }
    await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands });

    console.log('Successfully reloaded application (/) commands.');
  } catch (error) {
    console.error(error);
  }
})();

Step 15: Discord Bot – Part 3 (Command Handler)

Create an event listener that responds when users execute the /find command. This handler validates the tag input, queries Supabase for matching bookmarks, and sends the results back as Discord embeds:

// Event: Slash command interaction
client.on('interactionCreate', async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  if (interaction.commandName === 'find') {
    let tag = interaction.options.getString('tag');
    if (!tag) {
      await interaction.reply({
        content: 'Please provide a valid tag.',
        ephemeral: true,
      });
      return;
    }

    // Convert the tag to lowercase for case-insensitive matching
    tag = tag.toLowerCase();

    try {
      // Query Supabase for the tag (case-insensitive)
      const { data: tagData, error: tagError } = await supabase
        .from('tags')
        .select('id')
        .ilike('name', tag)
        .single();

      if (tagError || !tagData) {
        await interaction.reply({
          content: `No raindrops found for the tag "${tag}".`,
          ephemeral: true,
        });
        return;
      }

      const tagId = tagData.id;

      // Query Supabase for raindrops associated with the tag
      const { data: raindrops, error: raindropsError } = await supabase
        .from('raindrop_tags')
        .select('raindrop_id')
        .eq('tag_id', tagId);

      if (raindropsError || !raindrops || raindrops.length === 0) {
        await interaction.reply({
          content: `No raindrops found for the tag "${tag}".`,
          ephemeral: true,
        });
        return;
      }

      // Fetch raindrop details
      const raindropIds = raindrops.map((r) => r.raindrop_id);
      const { data: raindropDetails, error: raindropDetailsError } = await supabase
        .from('raindrops')
        .select('id, title, link')
        .in('id', raindropIds);

      if (raindropDetailsError || !raindropDetails || raindropDetails.length === 0) {
        await interaction.reply({
          content: `No raindrop details found for the tag "${tag}".`,
          ephemeral: true,
        });
        return;
      }

      // Create embeds for each raindrop
      const embeds = await Promise.all(
        raindropDetails.map(async (raindrop) => {
          const embed: { title: string; url: string; description: string; image?: { url: string } } = {
            title: raindrop.title,
            url: raindrop.link,
            description: `Link: [Click here](${raindrop.link})`,
          };

          // Query the media table for an image associated with the raindrop
          const { data: media, error: mediaError } = await supabase
            .from('media')
            .select('link, type')
            .eq('raindrop_id', raindrop.id)
            .eq('type', 'image')
            .limit(1)
            .single();

          if (mediaError) {
            console.error(`Error fetching media for raindrop ID "${raindrop.id}":`, mediaError.message);
          }

          // Optionally add image to embed (currently commented out)
          // if (media && media.link) {
          //   embed.image = { url: media.link };
          // }

          return embed;
        })
      );

      await interaction.reply({
        content: `Raindrops for the tag "${tag}":`,
        embeds: embeds,
        ephemeral: true,
      });
    } catch (error) {
      console.error('Error processing /find command:', error);
      await interaction.reply({
        content: 'An error occurred while processing your request.',
        ephemeral: true,
      });
    }
  }
});

Step 16: Discord Bot – Part 4 (Login)

Add the login call at the end of src/discordBot.ts:

// Log in to Discord
client.login(DISCORD_TOKEN);

Step 17: Package.json Scripts

Update your package.json with convenient scripts:

{
  "name": "raindrop-backup-bot",
  "version": "1.0.0",
  "description": "Backup Raindrop.io bookmarks to Supabase and search via Discord",
  "main": "index.js",
  "scripts": {
    "backup": "npx tsc && node dist/backupRaindrop.js",
    "bot": "npx tsc && node dist/discordBot.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@supabase/supabase-js": "^2.50.0",
    "axios": "^1.10.0",
    "discord.js": "^14.21.0",
    "dotenv": "^16.6.1"
  },
  "devDependencies": {
    "@types/node": "^24.0.3",
    "ts-node": "^10.9.2",
    "typescript": "^5.8.3"
  }
}

Step 18: Running the Project

First Time Setup

  1. Run the database migration in Supabase (copy the SQL from Step 6 into the SQL editor)
  2. Fill in your .env file with all credentials
  3. Install dependencies:
npm install

Running the Backup Job

To sync your Raindrop.io bookmarks to Supabase:

npm run backup

This will:

  • Fetch all your collections from Raindrop.io
  • Insert them into Supabase with proper relationships
  • Fetch all bookmarks (raindrops) from each collection
  • Store all associated tags and media

You can run this command manually or set it up as a cron job to run regularly.

Running the Discord Bot

To start the Discord bot:

npm run bot

The bot will:

  • Connect to Discord
  • Register the /find slash command
  • Listen for interactions
  • Query Supabase when users search for tags

Step 19: Using the Discord Bot

Once your bot is running and invited to your Discord server:

  1. Type /find in any channel
  2. Enter a tag name (e.g., “typescript”, “tutorials”, etc.)
  3. The bot will search your Supabase database and return matching bookmarks as embeds

Example usage:

/find tag:javascript

The bot will return all bookmarks tagged with “javascript” showing:

  • Title
  • Link (clickable)
  • Associated media (if enabled)

Step 20: Setting Up a Cron Job (Optional)

To automatically sync your bookmarks daily, you can set up a cron job:

On Linux/Mac

Edit your crontab:

crontab -e

Add this line to run the backup daily at 2 AM:

0 2 * * * cd /path/to/raindrop-backup-bot && npm run backup >> /var/log/raindrop-backup.log 2>&1

Using PM2 (Recommended)

Install PM2 globally:

npm install -g pm2

Create a PM2 ecosystem file ecosystem.config.js:

module.exports = {
  apps: [
    {
      name: 'discord-bot',
      script: 'dist/discordBot.js',
      cron_restart: '0 2 * * *',
      autorestart: true,
    },
    {
      name: 'raindrop-backup',
      script: 'dist/backupRaindrop.js',
      cron_restart: '0 2 * * *',
      autorestart: false,
    }
  ]
};

Start your services:

npm run backup  # Initial sync
pm2 start ecosystem.config.js
pm2 save
pm2 startup

Conclusion

You now have a fully functional system that:

  • Backs up your Raindrop.io bookmarks to a PostgreSQL database
  • Preserves all metadata including collections, tags, and media
  • Provides a searchable interface via Discord bot
  • Can be automated to run on a schedule

This architecture is extensible and can be adapted for other bookmark services or enhanced with additional features. The separation between data sync and retrieval makes it easy to add new interfaces (web, mobile, Slack, etc.) without modifying the core backup logic.

The complete source code for this project is available, and you can customize it to fit your specific needs. Happy bookmarking!

Resources


This content originally appeared on DEV Community and was authored by Greg Holmes