DEV Community

Josh Elberg
Josh Elberg

Posted on

Building Brighten: Technical Deep Dive into an Employee Recognition Platform

We just launched Brighten on Product Hunt — an employee recognition platform with native Slack/Teams integration. Here's how we built it.

The Challenge

Build a real-time employee recognition platform that:

  • Integrates natively with Slack/Teams (webhook-driven)
  • Handles enterprise compliance (SSO, audit logs, SOC2)
  • Scales to thousands of recognitions per second
  • Stays free for small teams (cost optimization)

Tech Stack

Frontend:     Next.js 14 (App Router) + React 18
Backend:      Next.js API Routes + Server Actions
Database:     Supabase (PostgreSQL + Real-time)
Auth:         Supabase Auth + OAuth + SAML
Hosting:      Vercel (Edge Runtime)
Integrations: Slack Web API + Microsoft Graph API
Payments:     Stripe Connect
Enter fullscreen mode Exit fullscreen mode

Key Technical Decisions

1. Real-time over Batch Processing

Decision: Process recognitions in real-time, not batches.

Why: Recognition loses impact after 24 hours. If someone recognizes you at 2 PM and you get notified at 9 AM the next day, it feels stale.

Implementation:

// Supabase real-time subscription
const channel = supabase
  .channel('recognitions')
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'recognitions' },
    async (payload) => {
      await sendSlackNotification(payload.new);
      await updateEngagementScore(payload.new.recipient_id);
    }
  )
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

Trade-off: Higher database load, but worth it for user experience.

2. Webhook-First Integrations

Decision: Build deep integrations, not just bots.

Why: Slack/Teams adoption dies if there's any friction. Users shouldn't have to @ a bot or remember commands.

Implementation:

// Slack slash command
app.command('/brighten', async ({ command, ack, client }) => {
  await ack();

  // Parse: /brighten @user for [reason]
  const { user, reason } = parseCommand(command.text);

  // Create recognition
  const recognition = await createRecognition({
    sender_id: command.user_id,
    recipient_id: user,
    reason: reason,
    channel_id: command.channel_id,
  });

  // Post to channel
  await client.chat.postMessage({
    channel: command.channel_id,
    text: `🎉 ${command.user_name} recognized ${user} for ${reason}`,
    blocks: buildRecognitionBlocks(recognition),
  });
});
Enter fullscreen mode Exit fullscreen mode

Result: Teams actually keep using it — adoption stays strong well beyond the first week.

3. Compliance-First Architecture

Decision: Build security in, not bolt it on later.

Why: HR data requires SOC2, GDPR, audit logs from day 1. Retrofitting security is expensive and risky.

Implementation:

-- Row-level security
CREATE POLICY "Users can only see their own org data"
  ON recognitions
  FOR SELECT
  USING (org_id = auth.uid()::uuid);

-- Audit logging
CREATE OR REPLACE FUNCTION audit_log()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO audit_logs (
    table_name, action, user_id, old_data, new_data
  ) VALUES (
    TG_TABLE_NAME, TG_OP, auth.uid(), to_jsonb(OLD), to_jsonb(NEW)
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Enter fullscreen mode Exit fullscreen mode

Result: Enterprise-ready from launch.

4. Multi-Tenant with Single Database

Decision: Single database with row-level security, not separate databases per tenant.

Why:

  • Easier to maintain (one schema, one migration path)
  • Better resource utilization (shared connection pool)
  • Simpler backup/restore
  • Future-proof for cross-org features (benchmarking, industry comparisons)

Implementation:

-- Every table has org_id
CREATE TABLE recognitions (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  org_id UUID NOT NULL REFERENCES organizations(id),
  sender_id UUID NOT NULL,
  recipient_id UUID NOT NULL,
  reason TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- RLS ensures data isolation
ALTER TABLE recognitions ENABLE ROW LEVEL SECURITY;
Enter fullscreen mode Exit fullscreen mode

Trade-off: Slightly more complex queries, but worth it for operational simplicity.

5. Edge Runtime for Global Performance

Decision: Deploy to Vercel Edge Runtime (not Node.js runtime).

Why: Recognition needs to feel instant everywhere. 200ms matters.

Implementation:

// app/api/recognitions/route.ts
export const runtime = 'edge'; // Deploy to 70+ edge locations

export async function POST(req: Request) {
  const recognition = await req.json();

  // Ultra-fast database queries via Supabase HTTP API
  const { data } = await supabase
    .from('recognitions')
    .insert(recognition)
    .select()
    .single();

  return Response.json(data);
}
Enter fullscreen mode Exit fullscreen mode

Result: <100ms response times globally.

Hardest Problems

Problem 1: Making Peer-to-Peer Recognition Feel Authentic

Challenge: How do you encourage recognition without making it feel forced or gamified?

Bad solutions we rejected:

  • ❌ Points system (turns recognition into a game)
  • ❌ Leaderboards (creates competition, not gratitude)
  • ❌ Forced cadence ("You must recognize 2 people this week")

Our solution:

  • No points
  • No leaderboards
  • No forced behavior
  • Just "thank you" when it matters

Result: Users consistently say recognition feels more authentic than previous tools.

Problem 2: Preventing Abuse

Challenge: What stops people from recognizing their friends 100 times for fake reasons?

Our solution:

// Recognition limits
const LIMITS = {
  per_day: 10,           // Max 10 recognitions per user per day
  same_recipient: 3,     // Max 3 to same person per week
  cooldown: 60 * 5,      // 5 min between recognitions
};

// Anomaly detection
async function detectAnomalies(recognition) {
  const recent = await getRecentRecognitions(recognition.sender_id);

  // Flag if all recognitions to same person
  if (recent.every(r => r.recipient_id === recognition.recipient_id)) {
    await flagForReview(recognition);
  }

  // Flag if reason is copy-paste
  if (recent.some(r => r.reason === recognition.reason)) {
    await flagForReview(recognition);
  }
}
Enter fullscreen mode Exit fullscreen mode

Result: <1% of recognitions flagged for review.

Problem 3: Cost Optimization for Free Tier

Challenge: How do you offer a free tier without losing money?

Our approach:

// Free tier limits
const FREE_TIER = {
  max_users: 10,
  custom_award_types: 2,
  data_retention: 7,       // days
  reward_budget: 0,        // Can give recognition, but no monetary rewards
};
Enter fullscreen mode Exit fullscreen mode

Cost breakdown:

  • Free tier: $0/mo (up to 10 users, limited features)
  • Starter: $49/mo | Growth: $149/mo | Pro: $299/mo
  • Platform fees on rewards range from 5%–12% depending on tier

Strategy: Free tier drives virality. Paid tiers unlock rewards marketplace, integrations, and analytics.

Performance Metrics

After 3 months in beta:

Metric Target Actual
Page load (p95) <2s 1.3s
API response (p95) <500ms 230ms
Recognition delivery <5s 2.1s
Uptime >99.5% 99.8%
Database load <60% 42%

Lessons Learned

  1. Real-time matters in HR tech - Users expect instant feedback
  2. Compliance is not optional - Build it from day 1
  3. Deep integrations > bots - Friction kills adoption
  4. Free tier drives growth - Teams upgrade naturally as they scale
  5. Edge runtime is worth it - Global performance matters

We're Launching Today

Brighten is live on Product Hunt. Free for up to 10 users ($0/mo).

Try it: hellobrighten.com
Product Hunt: Brighten on Product Hunt

Would love feedback from other devs building in the SaaS/HR space!

Questions?

Drop them in the comments. I'll be here all day answering questions about the tech stack, architecture decisions, or building in public.


Built with Next.js, Supabase, and too much coffee ☕

Top comments (0)