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:
- A TypeScript cron job that syncs Raindrop.io collections and bookmarks to Supabase.
- 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
- Go to Raindrop.io App Settings
- Create a new app to get your
client_id
andclient_secret
- Follow the OAuth flow to get your
refresh_token
Getting Supabase Credentials
- Create a new project at Supabase
- Go to Project Settings > API
- Copy your project URL and service role key
Getting Discord Bot Credentials
- Go to Discord Developer Portal
- Create a new application
- Go to Bot section and create a bot
- Copy the bot token
- 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:
- Fetch all collections from Raindrop.io
- Insert them into Supabase with proper parent-child relationships
- Fetch all raindrops (bookmarks) from each collection
- 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
- Run the database migration in Supabase (copy the SQL from Step 6 into the SQL editor)
- Fill in your
.env
file with all credentials - 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:
- Type
/find
in any channel - Enter a tag name (e.g., “typescript”, “tutorials”, etc.)
- 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