Building a Professional Services Platform for Government Tender Compliance
When we set out to build Tenders SA, we knew that matching businesses with government tenders was only half the battle. The real barrier preventing capable companies from accessing billions in procurement opportunities wasn't finding tenders—it was compliance documentation.
This is the story of how we built a full-stack professional services platform that handles B-BBEE registrations, CSD applications, company formations, and CIDB grading—all while maintaining a seamless user experience and reliable payment processing.
The Problem: Compliance is Complicated
In South Africa, bidding on government tenders requires a maze of registrations:
- B-BBEE (Broad-Based Black Economic Empowerment) certification
- CSD (Central Supplier Database) registration with National Treasury
- CIPC company registration
- CIDB grading for construction businesses
- Tax compliance certificates
- Insurance documentation
- And more...
Each registration has its own portal, requirements, and quirks. DIY registration typically takes 6-12 weeks and involves multiple submission attempts. We wanted to compress this to 7-10 days with professional handling.
Tech Stack: Why We Chose What We Chose
// Our core stack
const techStack = {
framework: "Next.js 14 (App Router)",
language: "TypeScript",
database: "PostgreSQL + Prisma ORM",
auth: "Custom JWT + Session management",
payments: "PayPal REST API",
storage: "AWS S3 for document uploads",
deployment: "Vercel",
};
Why Next.js App Router?
The App Router's server components gave us some huge wins:
- Server-side data fetching - No loading spinners for service listings
- Streaming - Progressive rendering for document-heavy pages
- Route handlers - Clean API routes that live alongside components
- Type safety - Full TypeScript support from DB to UI
Architecture: Service Request System
The core of our platform is a flexible service request system. Here's the Prisma schema:
model ServiceType {
id String @id @default(cuid())
name String
slug String @unique
description String
price Decimal @db.Decimal(10, 2)
currency String @default("ZAR")
isActive Boolean @default(true)
requests ServiceRequest[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ServiceRequest {
id String @id @default(cuid())
userId String
serviceTypeId String
// Flexible JSON storage for form data
formData Json
// Document storage keys
uploadedFiles Json?
// State management
status RequestStatus @default(PENDING)
paymentStatus PaymentStatus @default(PENDING)
// PayPal integration
paypalOrderId String? @unique
// Relationships
user User @relation(fields: [userId], references: [id])
serviceType ServiceType @relation(fields: [serviceTypeId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
}
enum RequestStatus {
DRAFT
PENDING
PROCESSING
COMPLETED
REJECTED
}
enum PaymentStatus {
PENDING
PAID
FAILED
REFUNDED
}
The key insight here was using JSON fields for form data. Each service has different requirements:
- B-BBEE needs turnover figures and ownership percentages
- CSD needs CIPC numbers and tax references
- Business registration needs director details and company names
Rather than creating separate tables for each service type, we use a polymorphic approach with type-safe validation.
Form Handling: Dynamic, Type-Safe Forms
Each service has its own form component with Zod validation. Here's the B-BBEE registration schema:
// lib/schemas/professional-services.ts
import { z } from "zod";
export const beeRegistrationSchema = z.object({
companyName: z.string().min(2, "Company name required"),
registrationNumber: z.string().min(10, "Valid registration number required"),
// Turnover determines if you need affidavit or certificate
annualTurnover: z.coerce.number().min(0),
// Ownership structure
blackOwnership: z.coerce.number().min(0).max(100),
blackWomenOwnership: z.coerce.number().min(0).max(100),
// Supporting documents
cipcDocumentKey: z.string().min(1, "CIPC document required"),
financialStatementsKey: z.string().optional(),
});
export type BeeRegistrationInput = z.infer<typeof beeRegistrationSchema>;
The form component uses React Hook Form for optimal UX:
// components/services/forms/BeeRegistrationForm.tsx
'use client';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
export function BeeRegistrationForm() {
const form = useForm<BeeRegistrationInput>({
resolver: zodResolver(beeRegistrationSchema),
defaultValues: {
blackOwnership: 0,
blackWomenOwnership: 0,
},
});
async function onSubmit(data: BeeRegistrationInput) {
const result = await submitBeeRegistration(data);
if (result.success) {
// Redirect to payment
window.location.href = `/services/bee-registration/review/${result.data.requestId}`;
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
</Form>
);
}
File Uploads: S3 with Presigned URLs
Handling documents securely was critical. We use presigned S3 URLs for direct client-to-S3 uploads:
// app/api/v1/upload/route.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3Client = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function POST(request: Request) {
const { filename, contentType } = await request.json();
const key = `uploads/${Date.now()}-${filename}`;
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET!,
Key: key,
ContentType: contentType,
});
// Generate presigned URL valid for 5 minutes
const uploadUrl = await getSignedUrl(s3Client, command, {
expiresIn: 300,
});
return Response.json({ uploadUrl, key });
}
This approach:
- ✅ Keeps file uploads off our server
- ✅ Provides better upload speeds (direct to S3)
- ✅ Handles large files gracefully
- ✅ Maintains security with expiring URLs
Payment Integration: PayPal REST API
Processing payments was tricky. South African businesses needed local payment options but also international flexibility. PayPal's REST API gave us both.
// lib/paypal.ts
interface PayPalOrder {
id: string;
status: string;
purchase_units: Array<{
amount: {
currency_code: string;
value: string;
};
}>;
}
export async function createPayPalOrder(
amount: number,
currency: string,
requestId: string
): Promise<PayPalOrder> {
const accessToken = await getPayPalAccessToken();
const response = await fetch(`${PAYPAL_API}/v2/checkout/orders`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [{
amount: {
currency_code: currency,
value: amount.toFixed(2),
},
reference_id: requestId,
}],
application_context: {
brand_name: "Tenders SA",
landing_page: "NO_PREFERENCE",
user_action: "PAY_NOW",
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/services/payment/success`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/services/payment/cancel`,
},
}),
});
return response.json();
}
The payment flow:
- Create Order - Generate PayPal order with service request ID
- User Redirects - PayPal handles the payment UI
- Capture Payment - On return, we capture and verify
- Update Status - Mark request as PAID and trigger processing
// app/actions/payment.ts
'use server';
export async function captureServicePayment(token: string) {
const accessToken = await getPayPalAccessToken();
// Capture the payment
const response = await fetch(
`${PAYPAL_API}/v2/checkout/orders/${token}/capture`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
}
);
const data = await response.json();
if (data.status === "COMPLETED") {
// Extract reference_id (our request ID)
const requestId = data.purchase_units[0].reference_id;
// Update database
await db.serviceRequest.update({
where: { id: requestId },
data: {
paymentStatus: "PAID",
status: "PROCESSING",
paypalOrderId: token,
},
});
// Trigger notification to our team
await notifyTeamOfNewRequest(requestId);
return { success: true };
}
return { success: false, message: "Payment failed" };
}
Review Page: Pre-Payment Confirmation
We added a crucial step between form submission and payment—a review page:
// app/services/business-registration/review/[requestId]/page.tsx
export default async function ReviewRegistrationPage({
params
}: {
params: Promise<{ requestId: string }>
}) {
const user = await getCurrentUser();
const { requestId } = await params;
const request = await db.serviceRequest.findUnique({
where: { id: requestId },
include: { serviceType: true },
});
// Parse stored form data
const formData = JSON.parse(request.formData as string);
const servicePrice = request.serviceType.price.toNumber();
return (
<div className="container max-w-3xl py-10">
<h1>Review Application</h1>
{/* Display company names, directors, etc */}
<CompanyDetailsCard data={formData} />
<DirectorsCard directors={formData.directors} />
{/* Payment section */}
<PaymentCard
requestId={requestId}
amount={servicePrice}
currency="ZAR"
/>
</div>
);
}
This reduced payment drop-off by 42% because users could verify their details before committing to payment.
Server Actions: The Secret Sauce
Next.js Server Actions made our forms feel instant while maintaining security:
// app/actions/professional-services.ts
'use server';
import { getCurrentUser } from "@/lib/session";
import { db } from "@/lib/db";
export async function submitBusinessRegistration(
data: BusinessRegistrationInput
) {
// Server-side auth check
const user = await getCurrentUser();
if (!user) {
return {
success: false,
message: "Unauthorized. Please log in.",
};
}
try {
// Validate data server-side (double validation)
const validated = businessRegistrationSchema.parse(data);
// Create service request
const request = await db.serviceRequest.create({
data: {
userId: user.id,
serviceTypeId: "business-registration-id",
formData: validated,
status: "DRAFT",
paymentStatus: "PENDING",
},
});
return {
success: true,
data: { requestId: request.id },
};
} catch (error) {
return {
success: false,
message: "Failed to submit application",
errors: error instanceof z.ZodError ? error.errors : [],
};
}
}
Server Actions give us:
- ✅ No API route boilerplate
- ✅ Automatic revalidation
- ✅ Progressive enhancement
- ✅ Type-safe RPC-like calls
Dashboard: Service Request Management
Users needed visibility into their applications. We built a dashboard with real-time status updates:
// app/dashboard/services/page.tsx
export default async function ServicesPage() {
const user = await getCurrentUser();
const requests = await db.serviceRequest.findMany({
where: { userId: user.id },
include: { serviceType: true },
orderBy: { createdAt: 'desc' },
});
return (
<div className="space-y-6">
<h1>My Service Requests</h1>
<div className="grid gap-4">
{requests.map(request => (
<ServiceRequestCard
key={request.id}
request={request}
/>
))}
</div>
</div>
);
}
Each request card shows:
- Service type and price
- Submission date
- Current status (with color-coded badges)
- Payment status
- Action buttons (view details, pay now, download documents)
Lessons Learned
1. JSON Schema Validation is Your Friend
Using Zod for both client and server validation caught so many issues:
// This caught bugs we'd have missed
const schema = z.object({
directors: z.array(
z.object({
idNumber: z.string()
.length(13, "SA ID must be 13 digits")
.regex(/^\d+$/, "ID must be numeric"),
})
).min(1, "At least one director required"),
});
2. Type-Safe Form Data with Discriminated Unions
Different services need different forms, but we wanted type safety:
type ServiceFormData =
| { type: 'bee'; data: BeeRegistrationInput }
| { type: 'csd'; data: CsdRegistrationInput }
| { type: 'cidb'; data: CidbRegistrationInput };
function getFormComponent(serviceType: string) {
switch (serviceType) {
case 'bee-registration':
return BeeRegistrationForm;
case 'csd-registration':
return CsdRegistrationForm;
default:
return GeneralServiceForm;
}
}
3. Idempotent Payment Capture is Critical
We learned this the hard way when a user refreshed the payment success page:
export async function captureServicePayment(token: string) {
// Check if already captured
const existing = await db.serviceRequest.findUnique({
where: { paypalOrderId: token },
});
if (existing?.paymentStatus === "PAID") {
return { success: true, message: "Already processed" };
}
// Proceed with capture...
}
4. Progressive Enhancement with Server Components
Our forms work without JavaScript! Well, mostly:
export function ServiceForm({ serviceSlug }: { serviceSlug: string }) {
// This renders on the server
const FormComponent = getFormComponent(serviceSlug);
return (
<Suspense fallback={<FormSkeleton />}>
<FormComponent />
</Suspense>
);
}
Performance Metrics
After 6 months of operation:
- Average submission time: 3.2 minutes (down from 45+ minutes DIY)
- Payment completion rate: 87% (industry average is 60%)
- Processing time: 7-9 days (down from 21+ days DIY)
- Error rate: <2% (mostly user-uploaded document issues)
- P95 page load: 1.8s
What's Next?
We're working on:
- Automatic document generation - Generate MOIs, shareholder agreements, etc.
- Smart document validation - AI checks for common errors before submission
- Status webhooks - Real-time updates via email/SMS
- Bulk services - Handle multiple registrations at once
- API access - Let accounting firms integrate our services
Open Source Opportunities
We're considering open-sourcing:
- Our Zod schema collection for SA compliance documents
- The PayPal integration wrapper (with better TypeScript types)
- Document upload component with S3 presigned URLs
Would this be useful to you? Let me know in the comments!
Conclusion
Building this platform taught us that compliance doesn't have to be painful. With the right architecture—type-safe schemas, flexible data models, reliable payments—you can turn a 3-month bureaucratic nightmare into a 10-day professional service.
The South African government procurement market is worth R500+ billion annually. By removing the compliance barrier, we're helping thousands of capable businesses access opportunities they previously couldn't pursue.
If you're building similar document-heavy, payment-processing platforms, I hope this breakdown helps. Feel free to ask questions in the comments!
Tech Stack Summary:
- Next.js 14 (App Router)
- TypeScript
- Prisma + PostgreSQL
- React Hook Form + Zod
- PayPal REST API
- AWS S3
- Vercel
GitHub: (Coming soon)
Website: tenders-sa.org
Read Next: Building a Tender Tools Suite with AI-Powered Calculators
Have you built similar compliance or document management platforms? What challenges did you face? Drop a comment below!
Top comments (0)