Skip to main content
The 200 Mates application uses Supabase as its backend-as-a-service, providing PostgreSQL database, file storage, and real-time capabilities.

Supabase Setup

Client Initialization

config/constants.js
const SUPABASE_URL = "https://qpwwexlxiksmaaehqsev.supabase.co";
const SUPABASE_ANON = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFwd3dleGx4aWtzbWFhZWhxc2V2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE5ODk5MjEsImV4cCI6MjA4NzU2NTkyMX0.zfrepatjz41WDVIbxp61FblT8lKPyNpU-HB0RElIpgc";

const sb = supabase.createClient(SUPABASE_URL, SUPABASE_ANON);
The SUPABASE_ANON key is intentionally public and safe to expose in client-side code. Row-level security (RLS) policies on the backend restrict access appropriately.

Project Configuration

SettingValue
Project IDqpwwexlxiksmaaehqsev
RegionAuto-selected
DatabasePostgreSQL 15
Storage Bucketmate-photos (public)
AuthenticationAnonymous access only

Database Schema

mates Table

CREATE TABLE mates (
  id BIGSERIAL PRIMARY KEY,
  public_id TEXT,
  name TEXT NOT NULL,
  country TEXT NOT NULL,
  country_code TEXT,
  brand TEXT NOT NULL,
  preparation TEXT NOT NULL,
  mate_type TEXT,
  photo_path TEXT NOT NULL,
  lat DOUBLE PRECISION,
  lng DOUBLE PRECISION,
  approved BOOLEAN DEFAULT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Columns

ColumnTypeNullableDescription
idBIGSERIALNoAuto-increment primary key
public_idTEXTYesHuman-readable public ID
nameTEXTNoSubmitter’s name
countryTEXTNoCountry name (localized)
country_codeTEXTYesISO 3166-1 alpha-3 code
brandTEXTNoYerba mate brand
preparationTEXTNoPreparation style
mate_typeTEXTYesVessel type (optional)
photo_pathTEXTNoStorage filename
latDOUBLE PRECISIONYesLatitude
lngDOUBLE PRECISIONYesLongitude
approvedBOOLEANYesModeration status
created_atTIMESTAMPTZNoSubmission timestamp

Indexes

CREATE INDEX idx_mates_approved ON mates(approved);
CREATE INDEX idx_mates_created_at ON mates(created_at DESC);
CREATE INDEX idx_mates_country_code ON mates(country_code);
Indexes on approved and created_at optimize the main query that fetches approved mates in reverse chronological order.

Database Queries

Fetching Approved Mates

The main query retrieves all approved mate submissions:
app.js:18
async function loadMates() {
  try {
    const { data, error } = await sb
      .from("mates")
      .select("*")
      .not("approved", "eq", false)  // Exclude rejected mates
      .order("created_at", { ascending: false });

    if (error) throw error;

    allMates = data || [];
    
    // Process data
    const matesWithCoords = allMates
      .map(m => { 
        const c = resolveCoords(m); 
        return c ? { ...m, ...c } : null; 
      })
      .filter(Boolean);

    countriesColored = [...new Set(allMates.map(m => {
      if (m.country_code) return m.country_code.toUpperCase().trim();
      if (m.country) return nameToIso3[m.country.toLowerCase().trim()] || null;
      return null;
    }).filter(Boolean))];

    renderPolygons();
    renderMarkers(matesWithCoords);
    renderArcs(matesWithCoords);
    renderGallery();
    updateStats();

  } catch (err) {
    console.error("loadMates error:", err);
  }
}
Query Breakdown:
  1. .from("mates") - Query the mates table
  2. .select("*") - Select all columns
  3. .not("approved", "eq", false) - Include NULL (pending) and true (approved), exclude false (rejected)
  4. .order("created_at", { ascending: false }) - Sort newest first
The .not("approved", "eq", false) clause allows both pending (null) and approved (true) mates to display. This provides instant feedback to users while submissions await moderation.

Auto-Refresh Strategy

app.js:72
loadMates();
setInterval(loadMates, 30_000);  // Refresh every 30 seconds
New submissions appear within 30 seconds without requiring a page refresh.

Storage Bucket

mate-photos Bucket

Configuration:
  • Name: mate-photos
  • Public: Yes (read-only)
  • File size limit: 5 MB
  • Allowed file types: image/*

Uploading Photos

form.js:76
const photo = document.getElementById("photo").files[0];
const ext = photo.name.split(".").pop();
const fileName = `${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;

const { error: ue } = await sb.storage
  .from("mate-photos")
  .upload(fileName, photo, { 
    contentType: photo.type 
  });

if (ue) throw ue;
Filename Format:
{timestamp}_{random}.{extension}
Example:
1709801234567_k2j8x9q.jpg
The timestamp + random string combination ensures unique filenames and prevents collisions.

Generating Public URLs

utils.js:36
function getPhotoUrl(path) {
  if (!path) return null;
  const { data } = sb.storage.from("mate-photos").getPublicUrl(path);
  return data?.publicUrl || null;
}
Example Output:
https://qpwwexlxiksmaaehqsev.supabase.co/storage/v1/object/public/mate-photos/1709801234567_k2j8x9q.jpg

Inserting New Mates

form.js:80
const { error: ie } = await sb.from("mates").insert([{
  name,
  country,
  country_code: countryCode || null,
  brand,
  preparation,
  mate_type: mate_type || null,
  photo_path: fileName,
  lat: isNaN(lat) ? null : lat,
  lng: isNaN(lng) ? null : lng,
  approved: null  // Pending moderation
}]);

if (ie) throw ie;
New submissions have approved: null by default. They are visible immediately but should be reviewed by moderators. Consider implementing a moderation dashboard to set approved to true or false.

Row-Level Security (RLS)

While the current implementation allows anonymous access, these RLS policies are recommended for production:

Read Policy (Public)

CREATE POLICY "Public read access" ON mates
  FOR SELECT
  USING (approved IS NOT FALSE);  -- Allow NULL and TRUE

Insert Policy (Authenticated)

CREATE POLICY "Authenticated insert" ON mates
  FOR INSERT
  WITH CHECK (auth.role() = 'anon');  -- Allow anonymous inserts

Update Policy (Service Role Only)

CREATE POLICY "Service role update" ON mates
  FOR UPDATE
  USING (auth.role() = 'service_role');  -- Only backend can update

Delete Policy (Service Role Only)

CREATE POLICY "Service role delete" ON mates
  FOR DELETE
  USING (auth.role() = 'service_role');  -- Only backend can delete

Storage Policies

Upload Policy

CREATE POLICY "Public upload" ON storage.objects
  FOR INSERT
  WITH CHECK (bucket_id = 'mate-photos');

Read Policy

CREATE POLICY "Public read" ON storage.objects
  FOR SELECT
  USING (bucket_id = 'mate-photos');

Error Handling

Database Errors

try {
  const { data, error } = await sb.from("mates").select("*");
  if (error) throw error;
  // Process data
} catch (err) {
  console.error("Database error:", err);
  alert(`${t("alertError")}${err.message}`);
}

Common Error Codes

CodeDescriptionSolution
PGRST301Row not foundCheck query filters
PGRST116Invalid JSONValidate request body
42P01Table does not existCheck table name spelling
23505Unique violationHandle duplicate entries

Storage Errors

const { error } = await sb.storage.from("mate-photos").upload(fileName, file);
if (error) {
  if (error.statusCode === "413") {
    alert("File too large (max 5MB)");
  } else {
    alert(`Upload failed: ${error.message}`);
  }
}

Real-Time Subscriptions (Optional)

For instant updates, enable real-time subscriptions:
const channel = sb
  .channel('mates-changes')
  .on(
    'postgres_changes',
    { 
      event: '*', 
      schema: 'public', 
      table: 'mates',
      filter: 'approved=eq.true'
    },
    (payload) => {
      console.log('Change received!', payload);
      loadMates();  // Refresh data
    }
  )
  .subscribe();
Real-time subscriptions require enabling the Realtime feature in your Supabase project settings.

Performance Optimization

Query Optimization

Create indexes on frequently queried columns (approved, created_at, country_code).
For pagination, use .range(start, end) to limit results:
.range(0, 99)  // First 100 results
Only fetch needed columns to reduce payload size:
.select('id, name, country, photo_path, lat, lng')
Cache generated photo URLs to avoid redundant getPublicUrl() calls.

Connection Pooling

Supabase automatically handles connection pooling. For high-traffic applications, consider:
  • Enabling connection pooling in Supabase dashboard
  • Using edge functions for API endpoints
  • Implementing CDN caching for photos

Local Development

Supabase CLI Setup

For local development with Supabase:
# Install Supabase CLI
npm install -g supabase

# Initialize project
supabase init

# Start local Supabase
supabase start

# Update constants.js with local URL
const SUPABASE_URL = "http://localhost:54321";

Seed Data

Create supabase/seed.sql with sample mates:
INSERT INTO mates (name, country, country_code, brand, preparation, photo_path, lat, lng, approved)
VALUES
  ('Juan Pérez', 'Argentina', 'ARG', 'La Merced', 'amargo', 'sample1.jpg', -34.6037, -58.3816, true),
  ('Maria Silva', 'Brasil', 'BRA', 'Barao', 'chimarrao', 'sample2.jpg', -15.8267, -47.9218, true),
  ('Carlos González', 'Uruguay', 'URY', 'Canarias', 'amargo', 'sample3.jpg', -34.9011, -56.1645, true);

Migration Scripts

Create database migrations in supabase/migrations/:

20260101000000_create_mates_table.sql

CREATE TABLE mates (
  id BIGSERIAL PRIMARY KEY,
  public_id TEXT,
  name TEXT NOT NULL,
  country TEXT NOT NULL,
  country_code TEXT,
  brand TEXT NOT NULL,
  preparation TEXT NOT NULL,
  mate_type TEXT,
  photo_path TEXT NOT NULL,
  lat DOUBLE PRECISION,
  lng DOUBLE PRECISION,
  approved BOOLEAN DEFAULT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_mates_approved ON mates(approved);
CREATE INDEX idx_mates_created_at ON mates(created_at DESC);
CREATE INDEX idx_mates_country_code ON mates(country_code);

Apply Migrations

supabase db push

Next Steps

Data Structures

Understand the mate and country data models

Internationalization

Learn about the translation system

Build docs developers (and LLMs) love