The Problem: South Africa's Tender Opportunity Gap
In South Africa, government tenders represent billions of rands in business opportunities. Yet, small and medium businesses struggle to access these opportunities. The existing tender platforms are fragmented, difficult to navigate, and lack intelligent matching capabilities. Businesses spend hours searching through irrelevant opportunities, often missing perfect matches due to the sheer volume of daily tender publications.
We set out to solve this with Tenders SA - an AI-powered tender matching platform designed specifically for the South African market.
Core Vision: AI-Powered Tender Intelligence
Our vision was simple but ambitious:
- Centralize all South African tender opportunities in one place
- Intelligently match businesses with relevant opportunities using AI
- Simplify the tender response process with pre-filled applications
- Democratize access to government procurement opportunities
Technical Stack: Modern, Scalable, and AI-First
Frontend: Next.js 15 with App Router
We chose Next.js 15 for several compelling reasons:
// Our frontend architecture leverages modern React patterns
// with server-side rendering for optimal performance
// Example: Tender listing with automatic caching
export default async function TendersPage() {
const tenders = await fetchTenders({
cache: 'force-cache',
next: { revalidate: 3600 }
});
return <TenderList tenders={tenders} />;
}
Why Next.js 15?
- Server Components: Reduced client-side JavaScript bundle
- App Router: Intuitive routing with layouts and nested routes
- Edge Runtime: Deploy closer to users for lower latency
- Built-in Optimization: Automatic image optimization, code splitting
UI Framework: We paired this with Tailwind CSS and ShadCN UI components for a modern, accessible interface that works beautifully on mobile devices - crucial for South African users.
Backend: Node.js with Express and PostgreSQL
Our backend needed to handle:
- High-volume tender data synchronization
- Complex AI processing workloads
- Real-time notifications
- Secure authentication and authorization
// Core API structure
const express = require('express');
const app = express();
// RESTful API endpoints
app.get('/api/tenders', authMiddleware, getTenders);
app.post('/api/tenders/:id/match', aiMatchingMiddleware, matchTender);
app.get('/api/analytics', cacheMiddleware, getAnalytics);
// WebSocket for real-time notifications
io.on('connection', (socket) => {
socket.on('subscribe:tenders', (userId) => {
subscribeToTenderUpdates(userId, socket);
});
});
Key Technologies:
- Express.js: Fast, unopinionated web framework
- PostgreSQL: Robust relational database with excellent JSON support
- Prisma ORM: Type-safe database access with auto-generated types
- JWT Authentication: Secure, stateless authentication
Database Architecture: Domain-Driven Design
One of our most important architectural decisions was implementing a domain-based schema architecture:
// Instead of one massive schema file, we split into domains:
// - user-domain.prisma
// - tender-domain.prisma
// - organization-domain.prisma
// - ai-analysis-domain.prisma
// - notification-domain.prisma
// Auto-generated final schema
// npm run schema:build merges all domains
// This approach gave us:
// ✓ Better code organization
// ✓ Team collaboration without conflicts
// ✓ Domain-specific expertise
// ✓ Easier maintenance and testing
The AI Layer: Gemini Integration
The heart of Tenders SA is our AI-powered matching system:
interface TenderMatchingService {
// Analyze tender content
analyzeTender(tender: Tender): Promise<TenderAnalysis>;
// Match businesses with tenders
matchTenders(business: BusinessProfile): Promise<TenderMatch[]>;
// Generate response templates
generateResponseTemplate(
tender: Tender,
business: BusinessProfile
): Promise<ResponseTemplate>;
}
// Our AI pipeline
async function processTender(tender: RawTender) {
// 1. Content extraction
const content = await extractTenderContent(tender);
// 2. AI classification
const classification = await geminiService.classify(content);
// 3. Entity extraction
const entities = await extractKeyEntities(content);
// 4. Requirement analysis
const requirements = await analyzeRequirements(content);
// 5. Match scoring
return {
...tender,
classification,
entities,
requirements,
matchScore: calculateMatchScore(requirements)
};
}
Architecture Decisions: The Hard Trade-offs
1. Monorepo vs Multi-repo
Decision: Monorepo with domain separation
Why?
- Shared types and utilities between frontend/backend
- Atomic commits across full-stack features
- Simplified deployment pipeline
- Better developer experience
2. Real-time vs Polling
Decision: Hybrid approach
// Critical updates: WebSocket
socket.on('new_tender', (tender) => {
notify(user, tender);
});
// Background sync: Polling with exponential backoff
setInterval(() => {
syncTenderDatabase();
}, SYNC_INTERVAL);
3. Caching Strategy
Decision: Multi-layer caching
Client → Edge Cache (Vercel) → Redis → Database
(Static) (Session) (Hot data)
Performance Impact:
- 90% of requests served from cache
- Average response time: <100ms
- Database load reduced by 85%
The Data Challenge: eTenders.gov.za Integration
Initially, we planned to scrape eTenders.gov.za directly. We quickly realized this approach had critical flaws:
Problems:
- Unstable scraping targets (HTML changes break scrapers)
- Rate limiting and IP blocking
- No structured data access
- Legal and ethical concerns
Solution: We built a Cloudflare Worker API as an intermediary:
// Cloudflare Worker at tender-source.tenders-sa.org
export default {
async fetch(request, env) {
// 1. Authenticate request
const signature = request.headers.get('X-Signature');
if (!verifyHMAC(signature, env.SHARED_SECRET)) {
return new Response('Unauthorized', { status: 401 });
}
// 2. Check R2 cache
const cached = await env.R2_BUCKET.get(cacheKey);
if (cached) {
return new Response(cached.body, {
headers: { 'X-Cache': 'HIT' }
});
}
// 3. Fetch from source with retry logic
const data = await fetchWithRetry(sourceUrl);
// 4. AI-powered enrichment
const enriched = await enrichTenderData(data);
// 5. Cache in R2
await env.R2_BUCKET.put(cacheKey, JSON.stringify(enriched));
return new Response(JSON.stringify(enriched));
}
}
Benefits:
- Reliability: Edge computing with global distribution
- Performance: R2 caching reduces latency
- Security: HMAC-SHA256 authentication
- Intelligence: AI enrichment at the edge
- Cost-effective: Cloudflare's free tier covers us
Deployment Strategy: Vercel + Cloudflare
Frontend Deployment
# vercel.json
{
"buildCommand": "npm run build",
"framework": "nextjs",
"regions": ["iad1"], # Close to South African users via routing
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=3600, stale-while-revalidate=86400"
}
]
}
]
}
Backend API
- Deployed as Vercel serverless functions
- PostgreSQL hosted on Supabase for reliability
- Redis on Upstash for distributed caching
Worker API
# Deploy Cloudflare Worker
npm run deploy:worker
# Automatic deployment on git push
# GitHub Actions → Cloudflare Workers CI/CD
Early Results and Learnings
Performance Metrics
- Page Load Time: <1.5s (Africa average)
- API Response Time: <200ms average
- Tender Sync: Real-time with <5-minute delay
- Match Accuracy: 87% relevance score
What Worked
✅ Domain-based database schema reduced merge conflicts by 90%
✅ Cloudflare Worker API provided 99.9% uptime
✅ AI matching saved users 3+ hours per day
✅ Mobile-first design increased engagement by 45%
What We'd Do Differently
🔄 Start with serverless from day one (we migrated later)
🔄 Implement feature flags earlier for safer releases
🔄 Build comprehensive monitoring before launch
🔄 Create better documentation for onboarding
Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ Users │
│ (Web + Mobile) │
└───────────────────────┬─────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ Vercel Edge Network (Frontend) │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Next.js 15 │ │ Static │ │ API Routes │ │
│ │ SSR Pages │ │ Assets │ │ (Serverless) │ │
│ └─────────────┘ └──────────────┘ └─────────────────┘ │
└───────────────────────┬───────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ Backend Services │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Express API │ │ WebSocket │ │ Background │ │
│ │ (Node.js) │ │ Server │ │ Workers │ │
│ └──────────────┘ └──────────────┘ └────────────────┘ │
└───────────────────────┬───────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │ │ Gemini AI │
│ (Supabase) │ │ (Upstash) │ │ Service │
└──────────────┘ └──────────────┘ └──────────────┘
▲
│
┌───────────────────────────────────────────────────────────┐
│ Cloudflare Worker API (tender-source) │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ R2 Storage │ │ AI Enrich │ │ HMAC Auth │ │
│ │ (Caching) │ │ Pipeline │ │ Security │ │
│ └──────────────┘ └──────────────┘ └────────────────┘ │
└───────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────┐
│ eTenders.gov.za │
│ (Data Source) │
└──────────────────┘
What's Next?
In the next article, we'll dive deep into our Cloudflare Worker API - the critical infrastructure that made reliable tender synchronization possible. We'll cover:
- Why we chose Cloudflare Workers over traditional scraping
- Building a resilient data pipeline with R2 storage
- AI-powered content enrichment at the edge
- Security patterns for API authentication
- Performance optimization strategies
Key Takeaways
- Choose the right tools: Modern frameworks like Next.js 15 reduce boilerplate and improve performance
- Domain-driven design: Split complex systems into manageable domains
- Edge computing: Cloudflare Workers provide reliability and performance
- AI integration: Build AI as a first-class citizen, not an afterthought
- Mobile-first: South African users are predominantly mobile
About Tenders SA
Tenders SA is an AI-powered tender matching platform that helps South African businesses discover and win government contracts. We're democratizing access to procurement opportunities through intelligent automation.
Tech Stack: Next.js 15, Node.js, PostgreSQL, Prisma, Cloudflare Workers, Google Gemini AI
GitHub: [Coming soon - Open source components]
This is Part 1 of a 4-part series on building Tenders SA. Follow for more deep dives into real-world problems and solutions in the South African tech ecosystem.
Coming up:
- Part 2: Building a Resilient Cloudflare Worker API
- Part 3: AI-Powered Tender Matching with Gemini
- Part 4: Real-time Chat Backend with Socket.IO and Redis
Top comments (0)