Back to Blog

OnCampus – Full-Stack Campus Placement Management System

December 18, 2025 (3d ago)Siddharth Jain
ReactTypeScriptSupabaseTanStack QueryTailwind CSSRLSPostgreSQLViteshadcn-uiProject Showcase

Building a Full-Stack Campus Placement Management System with React, TypeScript, and Supabase

Introduction

Managing campus placements across students, TPO offices, and administrators requires robust access control, real-time data synchronization, and secure document handling. In this technical deep-dive, we'll explore how we built a comprehensive placement management platform that handles everything from Campus Application Forms (CAF) to placement drives and internship tracking—all while maintaining strict security boundaries through Row Level Security (RLS).

Tech Stack Overview

Our platform leverages modern web technologies for performance, type safety, and developer experience:

Architecture: Role-Based Access Control

Defining User Roles

The system supports three distinct roles defined as a PostgreSQL enum and enforced throughout the database:

CREATE TYPE user_role AS ENUM ('student', 'tpo', 'admin');

Each user profile links to auth.users and includes their role, with helper functions providing clean access checks:

Intelligent Route Protection

Authentication flows redirect users based on their role immediately after login:

// Dashboard.tsx routing logic
if (userRole === 'admin') navigate('/admin');
else if (userRole === 'tpo') navigate('/tpo');
// Students remain on /dashboard

Admins can create TPO accounts directly through the platform using Supabase's auth signup flow, maintaining full control over institutional access.

Data Model: The CAF Workflow

Multi-Table CAF Architecture

The Campus Application Form (CAF) is split across seven normalized tables to handle different data categories while maintaining referential integrity:

  1. caf_forms: Master record with overall status
  2. caf_personal: Personal and contact information
  3. caf_documents: Identity and residence proofs
  4. caf_academics: 10th, 12th, and degree details
  5. caf_semester_results: Per-semester CGPA tracking
  6. caf_professional: Skills, projects, certifications
  7. caf_edit_requests: Tracks TPO feedback and revision cycles

Status State Machine

CAF forms progress through a strict workflow:

draft → submitted → approved/rejected/edit_requested

Students can only edit forms in draft or edit_requested states. Once submitted, TPOs review and either approve, reject, or request edits with specific feedback. This gating mechanism prevents students from accessing placement drives until their CAF is approved.

Placement Drives: Complex Eligibility Logic

Dynamic Eligibility Criteria

Placement drives support granular eligibility rules stored as JSONB:

CREATE TABLE placement_drives (
  -- ... other fields
  eligibility JSONB DEFAULT '{
    "min_cgpa": 6.0,
    "max_backlogs": 2,
    "min_10th_percentage": 60,
    "min_12th_percentage": 60,
    "eligible_batches": [2025],
    "eligible_program_ids": []
  }'::jsonb,
  application_deadline TIMESTAMPTZ
);

Frontend Eligibility Validation

The student interface fetches both drives and academic records, computing eligibility client-side before allowing applications:

// PlacementDrivesStudent.tsx logic
const isEligible = (drive, academics) => {
  return (
    academics.cgpa >= drive.eligibility.min_cgpa &&
    academics.backlogs <= drive.eligibility.max_backlogs &&
    academics.tenth_percentage >= drive.eligibility.min_10th_percentage &&
    // ... batch and program checks
    new Date() <= new Date(drive.application_deadline)
  );
};

Applications are tracked in placement_applications with statuses including applied, shortlisted, interviewed, placed, and rejected.

Internship Management with Document Storage

Secure File Uploads

Internships require offer letter uploads stored in Supabase Storage with carefully scoped policies:

-- Storage policies for offer-letters bucket
CREATE POLICY "Public read access"
  ON storage.objects FOR SELECT
  USING (bucket_id = 'offer-letters');
 
CREATE POLICY "Owner write access"
  ON storage.objects FOR INSERT
  USING (bucket_id = 'offer-letters' AND auth.uid()::text = (storage.foldername(name))[1]);

Students can upload and update their own letters but cannot modify others'. The student_close_internship RPC allows marking completion dates without exposing direct table access.

Status-Based Edit Locking

Internship applications follow a lifecycle where students submit, TPOs approve/reject, and students can mark end dates. Once finalized (approved/rejected), the form becomes read-only to preserve audit trails.

TPO Dashboard: Bulk Operations and Workflows

CAF Review Interface

TPOs access a dedicated review interface that fetches complete student profiles with all CAF sections joined:

// CAFApprovals.tsx query
const { data: cafSubmissions } = useQuery({
  queryKey: ['caf-submissions'],
  queryFn: async () => {
    const { data } = await supabase
      .from('caf_forms')
      .select(`
        *,
        caf_personal(*),
        caf_academics(*),
        caf_documents(*),
        profiles(full_name, email)
      `)
      .eq('status', 'submitted');
    return data;
  }
});

The review dialog provides inline approve/reject/edit-request actions with feedback comments stored in caf_edit_requests.

Bulk Application Updates

For large placement drives, TPOs can bulk-update application statuses via an RPC that optionally suppresses individual notifications:

CREATE FUNCTION bulk_update_application_status(
  app_ids UUID[],
  new_status TEXT,
  suppress_notifications BOOLEAN DEFAULT false
)

This prevents notification spam when marking 50+ applications as shortlisted simultaneously.

Real-Time Notifications System

Trigger-Based Notification Creation

Database triggers automatically create notifications for key events:

-- Trigger on placement_drives insert
CREATE TRIGGER notify_new_placement_drive
AFTER INSERT ON placement_drives
FOR EACH ROW EXECUTE FUNCTION notify_new_drive();

Functions check eligibility criteria and create targeted notifications only for applicable students. Notification triggers also fire on CAF status changes and application updates, keeping all users informed without polling.

Frontend Notification Polling

The dashboard polls notifications every 30 seconds using TanStack Query's refetch interval:

const { data: notifications } = useQuery({
  queryKey: ['notifications'],
  refetchInterval: 30000, // 30 seconds
});

For production deployments, this could be replaced with Supabase real-time subscriptions for instant updates.

Security: Row Level Security Patterns

Self-Access vs. Staff Patterns

RLS policies follow consistent patterns across tables:

Students can read/write their own data:

CREATE POLICY "Students view own records"
ON students FOR SELECT
USING (auth.uid() = user_id);

Staff can access all records:

CREATE POLICY "Staff view all records"
ON students FOR SELECT
USING (is_staff());

Permissive Insert Policies

Initially restrictive insert policies were relaxed to allow students to create their own CAF sections and applications:

-- Migration 20251215145449
CREATE POLICY "Students can insert own CAF data"
ON caf_personal FOR INSERT
WITH CHECK (auth.uid() = user_id);

This pattern repeats across caf_* tables and placement_applications, ensuring students control their data while TPOs/admins retain oversight.

Frontend Architecture Patterns

Feature-Based File Organization

src/
├── pages/           # Route components
│   ├── Dashboard.tsx
│   ├── CAFForm.tsx
│   ├── PlacementDrivesStudent.tsx
│   └── TPODashboard.tsx
├── components/
│   ├── tpo/         # TPO-specific components
│   │   ├── CAFApprovals.tsx
│   │   ├── PlacementDrives.tsx
│   │   └── InternshipApprovals.tsx
│   └── ui/          # shadcn-ui primitives
└── lib/
    └── supabase.ts  # Client initialization

Each dashboard role gets its own page component that composes role-specific sub-components, maintaining clear separation of concerns.

Optimistic Updates with TanStack Query

Form submissions use optimistic updates to provide instant feedback:

const mutation = useMutation({
  mutationFn: submitCAF,
  onMutate: async (newData) => {
    await queryClient.cancelQueries(['caf']);
    const previous = queryClient.getQueryData(['caf']);
    queryClient.setQueryData(['caf'], newData);
    return { previous };
  },
  onError: (err, newData, context) => {
    queryClient.setQueryData(['caf'], context.previous);
  },
});

This pattern appears throughout the application for CAF updates, drive applications, and internship submissions.

Deployment Considerations

Environment Configuration

The application requires several Supabase environment variables:

VITE_SUPABASE_URL=your-project-url
VITE_SUPABASE_ANON_KEY=your-anon-key

Critical security note: The anon key is safe for client-side use due to RLS policies, but service role keys must never be exposed in frontend code or committed to version control.

Migration Strategy

Supabase migrations are ordered chronologically and build upon each other:

  1. Base schema with roles and core tables
  2. RLS policy adjustments for student inserts
  3. Placement drive eligibility enhancements
  4. Notification system with triggers
  5. Internship tables and storage policies
  6. Bulk operation RPCs

Migrations should be applied in sequence during deployment using supabase db push or equivalent CI/CD tooling.

Performance Optimizations

Database Indexing

Key foreign keys and frequently queried columns should have indexes:

CREATE INDEX idx_caf_forms_user_status ON caf_forms(user_id, status);
CREATE INDEX idx_placement_applications_drive ON placement_applications(drive_id);
CREATE INDEX idx_notifications_user_read ON notifications(user_id, is_read);

These indexes significantly speed up dashboard queries that filter by user and status.

Query Complexity Management

Complex joins in TPO dashboards (fetching drives with applications and student profiles) can be expensive. Consider:

Testing Strategy Gaps

The current implementation lacks automated testing coverage. Production deployments should include:

Playwright or Cypress would provide browser-based E2E coverage, while Jest + React Testing Library handle component testing.

Future Enhancements

Real-Time Collaboration

Replace polling with Supabase real-time subscriptions for instant notification delivery:

supabase
  .channel('notifications')
  .on('postgres_changes', {
    event: 'INSERT',
    schema: 'public',
    table: 'notifications',
    filter: `user_id=eq.${userId}`
  }, (payload) => {
    queryClient.invalidateQueries(['notifications']);
  })
  .subscribe();

Analytics Dashboard

TPOs and admins would benefit from visualizations showing:

Libraries like Recharts (already in the stack) can render these insights.

Mobile App

React Native with Supabase's cross-platform SDK would extend access to mobile, particularly useful for students checking drive updates and notifications on the go.

Conclusion

Building a campus placement system requires careful attention to access control, data integrity, and user experience across multiple roles. By leveraging Supabase's built-in auth, RLS, and real-time capabilities alongside React's component model and TypeScript's type safety, we created a maintainable platform that scales from small colleges to large universities.

The key architectural decisions—role-based routing, normalized CAF storage, RLS-enforced security, and trigger-based notifications—provide a solid foundation for extending the system with additional features like interview scheduling, resume parsing, or third-party ATS integrations.

Whether you're building HR tech, education platforms, or any multi-tenant application with complex workflows, these patterns demonstrate how modern serverless PostgreSQL platforms can replace traditional backend servers while maintaining enterprise-grade security and performance.


Live Demo: OnCampus
Tech Stack: React • TypeScript • Supabase • TanStack Query • Tailwind CSS