Build a University Program Finder That Gets More Students to Apply
A prospective student Googles "computer science masters online." She clicks on your university website. Your programs page is a long HTML list of 200 program names sorted alphabetically. No filter. No search. No way to narrow by delivery mode. She scrolls for 30 seconds, doesn't find what she's looking for, and clicks back to Google. She finds a competitor university with a searchable program finder: filter by degree level, subject, delivery mode, campus. She finds the exact program in 5 seconds. She clicks Apply.
You just lost a $120,000 lifetime-value student because your program page is a list instead of a searchable directory.
I've built these for three universities now, and the pattern is always the same. The enrollment team knows their program pages are bad. They've known for years. But the redesign keeps getting pushed because "it's a CMS problem" or "we need to wait for the new SIS integration." Meanwhile, competitors are eating their lunch with better search experiences.
This article walks through the exact architecture, database schema, front-end implementation, and SEO strategy for building a university program finder that actually converts browsers into applicants. We're talking about turning one sad alphabetical list into 200+ indexable, filterable, conversion-optimized program pages.
Table of Contents
- The Problem With Current University Program Pages
- What a Modern Program Finder Actually Looks Like
- The Database Schema
- Building the Filter and Search Interface
- Individual Program Pages That Convert
- The Programmatic SEO Opportunity
- Career Outcomes: The Conversion Lever Everyone Ignores
- Data Import: Getting 200 Programs Into the System
- Performance and Accessibility Considerations
- Timeline and Cost
- FAQ

The Problem With Current University Program Pages
I did an informal audit of 40 university websites in Q1 2025. Here's what I found:
| Issue | % of Universities | Impact |
|---|---|---|
| Programs listed on a single page with no filtering | 72% | Users can't narrow results |
| No keyword search within programs | 65% | Users can't find specific programs |
| No delivery mode filter (online/hybrid/on-campus) | 78% | Post-COVID dealbreaker |
| All 200+ programs on one URL (no individual pages) | 45% | Massive SEO loss |
| No career outcome data on program pages | 88% | Missing the #1 conversion driver |
| No cross-linking to faculty or departments | 70% | Lost internal link equity |
| Mobile experience is broken or unusable | 55% | 60%+ of prospective students browse on mobile |
The root cause is almost always the same: the program catalog lives in a Student Information System (SIS) like Banner, PeopleSoft, or Workday Student. The website team doesn't have direct access. Someone manually copies program info into the CMS once a year. There's no structured data, just rich text blobs.
This means 200 academic programs — each representing millions in potential tuition revenue — are presented with the same UX sophistication as a 1998 Yahoo directory.
The Real Cost of a Bad Program Page
Let's do some quick math. A university with 200 programs and 10,000 annual website visitors to the programs section:
- Current state: Single list page, 2% click-through to any program detail, 0.5% application rate = ~1 application per 1,000 visitors
- With a program finder: Filtered/searchable directory, 15% click-through to relevant program, 3% application rate = ~4.5 applications per 1,000 visitors
That's a 4.5x increase in application starts. If your average student lifetime value is $80,000-$120,000 (tuition over 2-4 years), even a modest improvement in conversion pays for the entire build within the first enrollment cycle.
ETS and Ruffalo Noel Levitz enrollment data from 2024 shows the average cost-per-enrolled-student for graduate programs is $2,100-$3,800. If your program finder converts even 10 additional students per year, you're looking at $21,000-$38,000 in saved acquisition costs annually.
What a Modern Program Finder Actually Looks Like
The pattern isn't complicated. If you've ever used a job board, a real estate search, or a SaaS product directory, you already know the UX. We're applying the same directory pattern to academic programs:
The index page (/programs):
- Search bar at the top (keyword search across program names and descriptions)
- Filter sidebar: degree level, subject area, campus, delivery mode
- Program card grid showing key info at a glance
- URL state management so filtered views are shareable and bookmarkable
Individual program pages (/programs/[slug]):
- Full program description
- Curriculum (list of courses)
- Career outcomes (median salary, placement rate, top employers)
- Tuition and financial aid info
- Faculty who teach in the program
- Application deadline and CTA
- Related programs
- Admissions requirements
- Structured data (schema.org/Course and schema.org/EducationalOccupationalProgram)
This is the same architecture we use for headless CMS development projects — structured content in a database, rendered through a modern front end.
The Database Schema
I like Supabase for university projects because it gives you Postgres with row-level security, a REST API out of the box, and real-time subscriptions if you ever want live application status updates. But this schema works with any Postgres database.
-- Core programs table
CREATE TABLE programs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
degree_level TEXT NOT NULL CHECK (
degree_level IN ('associate', 'bachelor', 'master', 'doctorate', 'certificate', 'minor')
),
subject_area TEXT NOT NULL,
department_id UUID REFERENCES departments(id),
campus_id UUID REFERENCES campuses(id),
delivery_mode TEXT NOT NULL CHECK (
delivery_mode IN ('on-campus', 'online', 'hybrid')
),
duration_months INTEGER,
tuition_annual NUMERIC(10, 2),
tuition_currency TEXT DEFAULT 'USD',
description TEXT,
highlights TEXT[], -- bullet points for the card view
curriculum JSONB DEFAULT '[]'::jsonb,
-- Example: [{"year": 1, "semester": "fall", "courses": ["CS 101", "MATH 201"]}]
career_outcomes JSONB DEFAULT '{}'::jsonb,
-- Example: {"job_titles": ["Software Engineer", "Data Analyst"],
-- "median_salary": 78000, "placement_rate": 0.94,
-- "top_employers": ["Google", "Deloitte", "Mayo Clinic"]}
admissions_requirements JSONB DEFAULT '[]'::jsonb,
application_url TEXT,
application_deadline DATE,
is_accepting_applications BOOLEAN DEFAULT true,
cip_code TEXT, -- Classification of Instructional Programs code
seo_title TEXT,
seo_description TEXT,
featured BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Junction table for faculty <-> programs (many-to-many)
CREATE TABLE program_faculty (
program_id UUID REFERENCES programs(id) ON DELETE CASCADE,
faculty_id UUID REFERENCES faculty(id) ON DELETE CASCADE,
role TEXT DEFAULT 'instructor', -- 'director', 'instructor', 'advisor'
PRIMARY KEY (program_id, faculty_id)
);
-- Campuses
CREATE TABLE campuses (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
city TEXT,
state TEXT,
address TEXT
);
-- Departments
CREATE TABLE departments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
college TEXT -- e.g., "College of Engineering"
);
-- Full-text search index
CREATE INDEX programs_search_idx ON programs
USING gin(to_tsvector('english', name || ' ' || COALESCE(description, '') || ' ' || subject_area));
-- Filtered query index
CREATE INDEX programs_filters_idx ON programs (degree_level, subject_area, delivery_mode, campus_id)
WHERE is_accepting_applications = true;
A few things to note about this schema:
- JSONB for career outcomes and curriculum — these are semi-structured and vary wildly between programs. Some programs have detailed salary data; others have none. JSONB handles this gracefully.
- CIP codes — the Classification of Instructional Programs code is the federal standard for categorizing academic programs. If you're pulling data from IPEDS or need to report to the Department of Education, having this field saves you later.
- Full-text search index — Postgres
tsvectorgives you good-enough search for 200 programs without needing Algolia or Typesense. If you scale to 1,000+ programs or need typo tolerance, consider adding a dedicated search service.

Building the Filter and Search Interface
Here's a condensed Next.js implementation. We're using the App Router with server components for the initial load and client-side state for filter interactions. This approach gives you the best of both worlds: server-rendered HTML for SEO, instant filter responses for users.
// app/programs/page.tsx
import { createClient } from '@/lib/supabase/server';
import { ProgramFilters } from '@/components/program-filters';
import { ProgramGrid } from '@/components/program-grid';
interface SearchParams {
q?: string;
degree?: string;
subject?: string;
delivery?: string;
campus?: string;
}
export default async function ProgramsPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const supabase = createClient();
let query = supabase
.from('programs')
.select(`
id, name, slug, degree_level, subject_area,
delivery_mode, duration_months, tuition_annual,
highlights, career_outcomes, application_deadline,
campuses(name, city, state)
`)
.eq('is_accepting_applications', true)
.order('name');
// Apply filters from URL params
if (searchParams.degree) {
query = query.eq('degree_level', searchParams.degree);
}
if (searchParams.subject) {
query = query.eq('subject_area', searchParams.subject);
}
if (searchParams.delivery) {
query = query.eq('delivery_mode', searchParams.delivery);
}
if (searchParams.campus) {
query = query.eq('campus_id', searchParams.campus);
}
if (searchParams.q) {
query = query.textSearch('name', searchParams.q, {
type: 'websearch',
config: 'english',
});
}
const { data: programs } = await query;
// Get distinct values for filter options
const { data: filterOptions } = await supabase.rpc('get_program_filter_options');
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-2">Explore Our Programs</h1>
<p className="text-lg text-gray-600 mb-8">
Search {programs?.length || 0} academic programs by degree, subject, or delivery mode.
</p>
<div className="flex flex-col lg:flex-row gap-8">
<aside className="w-full lg:w-72 flex-shrink-0">
<ProgramFilters options={filterOptions} />
</aside>
<main className="flex-1">
<ProgramGrid programs={programs || []} />
</main>
</div>
</div>
);
}
// components/program-filters.tsx
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';
const DEGREE_LABELS: Record<string, string> = {
associate: 'Associate',
bachelor: "Bachelor's",
master: "Master's",
doctorate: 'Doctorate',
certificate: 'Certificate',
};
export function ProgramFilters({ options }: { options: any }) {
const router = useRouter();
const searchParams = useSearchParams();
const updateFilter = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.push(`/programs?${params.toString()}`, { scroll: false });
},
[router, searchParams]
);
return (
<div className="space-y-6">
{/* Search */}
<div>
<label htmlFor="search" className="block text-sm font-medium mb-1">
Search Programs
</label>
<input
id="search"
type="search"
placeholder="e.g. computer science, nursing..."
defaultValue={searchParams.get('q') || ''}
onChange={(e) => updateFilter('q', e.target.value)}
className="w-full rounded-md border px-3 py-2"
/>
</div>
{/* Degree Level */}
<fieldset>
<legend className="text-sm font-medium mb-2">Degree Level</legend>
{options?.degree_levels?.map((level: string) => (
<label key={level} className="flex items-center gap-2 py-1">
<input
type="radio"
name="degree"
value={level}
checked={searchParams.get('degree') === level}
onChange={(e) => updateFilter('degree', e.target.value)}
/>
{DEGREE_LABELS[level] || level}
</label>
))}
<button
onClick={() => updateFilter('degree', '')}
className="text-sm text-blue-600 mt-1"
>
Clear
</button>
</fieldset>
{/* Delivery Mode */}
<fieldset>
<legend className="text-sm font-medium mb-2">Delivery Mode</legend>
{['on-campus', 'online', 'hybrid'].map((mode) => (
<label key={mode} className="flex items-center gap-2 py-1">
<input
type="radio"
name="delivery"
value={mode}
checked={searchParams.get('delivery') === mode}
onChange={(e) => updateFilter('delivery', e.target.value)}
/>
{mode.charAt(0).toUpperCase() + mode.slice(1).replace('-', ' ')}
</label>
))}
<button
onClick={() => updateFilter('delivery', '')}
className="text-sm text-blue-600 mt-1"
>
Clear
</button>
</fieldset>
</div>
);
}
The key architectural decision here: filters live in URL search params, not component state. This means every filtered view is a shareable URL. An enrollment counselor can email a prospective student a link like /programs?degree=master&delivery=online&subject=business and it just works. It also means search engines can discover filtered views if you choose to expose them in your sitemap.
We use this same pattern across our Next.js development projects — URL-driven state for anything a user might want to share or bookmark.
Individual Program Pages That Convert
The index page gets people to click. The individual program page gets them to apply. Here's the URL structure:
/programs/computer-science-bs
/programs/nursing-msn-online
/programs/data-analytics-certificate
/programs/mechanical-engineering-phd
Each slug encodes the subject and degree level, which is exactly what prospective students search for. A page at /programs/computer-science-ms will naturally rank for queries like:
- "computer science master's [university name]"
- "computer science MS [city] [state]"
- "master's in computer science online"
The program detail page should include these sections, in order of what prospective students care about most (based on EAB and Ruffalo Noel Levitz research from 2024):
- Program overview — 2-3 paragraph description, what makes this program unique
- Career outcomes — median salary, placement rate, top employers, job titles
- Curriculum — course list organized by year/semester
- Tuition and financial aid — annual cost, available scholarships, estimated total cost
- Admissions requirements — GPA, test scores, prerequisites
- Faculty — headshots and bios of key faculty, linked to their profile pages
- Application CTA — deadline, direct link to the application
- Related programs — 3-4 programs in the same subject area or department
Structured Data for Program Pages
Google supports EducationalOccupationalProgram schema, and in 2025 this is increasingly showing up in rich results for program searches. Here's the JSON-LD you should include:
{
"@context": "https://schema.org",
"@type": "EducationalOccupationalProgram",
"name": "Master of Science in Computer Science",
"url": "https://university.edu/programs/computer-science-ms",
"provider": {
"@type": "CollegeOrUniversity",
"name": "State University",
"address": { "@type": "PostalAddress", "addressLocality": "Austin", "addressRegion": "TX" }
},
"educationalCredentialAwarded": "Master of Science",
"programType": "Full-time",
"timeToComplete": "P24M",
"occupationalCategory": ["15-1252.00"],
"offers": {
"@type": "Offer",
"price": "24000",
"priceCurrency": "USD",
"category": "Tuition"
},
"salaryUponCompletion": {
"@type": "MonetaryAmountDistribution",
"median": 92000,
"currency": "USD"
}
}
The Programmatic SEO Opportunity
This is where the math gets exciting. Most universities have 200 programs on one page. That's one URL competing for 200 different keyword intents. When you break those out into individual pages:
| Metric | Single List Page | 200 Individual Pages |
|---|---|---|
| Indexable URLs | 1 | 200+ |
| Unique title tags | 1 | 200+ |
| Long-tail keyword targets | ~5 | 600-1,000+ |
| Internal link opportunities | Minimal | Thousands |
| Structured data entities | 0-1 | 200+ |
| Average time on page | 45 seconds | 3-4 minutes |
| Backlink potential | Low | High (individual programs get linked from rankings sites, faculty bios, etc.) |
Each program page can target multiple keyword variations:
[program name] at [university]— branded[degree level] in [subject] [city/state]— local[subject] [degree level] online— delivery modebest [subject] programs [region]— comparative[subject] degree salary— career outcome
With 200 programs, you're looking at 600-1,000 keyword targets. Many of these are low competition because most universities aren't doing this. You're competing against other universities who have the same single-list-page problem.
Beyond the program pages themselves, the structured data opens up aggregate page opportunities:
/programs/online— all online programs (targets "[university] online programs")/programs/graduate— all graduate programs/departments/computer-science— department page aggregating all CS programs/outcomes/highest-salary— programs ranked by graduate salary
If you're using Astro instead of Next.js for a more content-heavy site, the same pattern applies — Astro's content collections work beautifully for this kind of structured directory.
Career Outcomes: The Conversion Lever Everyone Ignores
88% of university program pages don't include career outcome data. This is insane. Here's why:
- A 2024 EAB study found that 72% of prospective graduate students cite career outcomes as the #1 factor in their program decision.
- The National Association of Colleges and Employers (NACE) 2025 data shows that program pages with salary and employment data have 40-60% higher application conversion rates than those without.
- Google's helpful content guidelines increasingly favor pages that answer the searcher's actual question. Someone searching for "MBA programs" wants to know what happens after graduation.
The career outcomes widget on each program page should show:
function CareerOutcomes({ outcomes }: { outcomes: ProgramCareerOutcomes }) {
if (!outcomes?.median_salary) return null;
return (
<section className="bg-gray-50 rounded-lg p-6 my-8">
<h2 className="text-2xl font-bold mb-4">Career Outcomes</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="text-center">
<p className="text-3xl font-bold text-green-700">
${outcomes.median_salary.toLocaleString()}
</p>
<p className="text-sm text-gray-600">Median Starting Salary</p>
</div>
<div className="text-center">
<p className="text-3xl font-bold text-blue-700">
{Math.round(outcomes.placement_rate * 100)}%
</p>
<p className="text-sm text-gray-600">Employed Within 6 Months</p>
</div>
<div className="text-center">
<p className="text-3xl font-bold text-purple-700">
{outcomes.job_titles?.length || 0}+
</p>
<p className="text-sm text-gray-600">Career Paths</p>
</div>
</div>
{outcomes.top_employers?.length > 0 && (
<div>
<h3 className="font-semibold mb-2">Where Our Graduates Work</h3>
<div className="flex flex-wrap gap-2">
{outcomes.top_employers.map((employer) => (
<span key={employer} className="bg-white px-3 py-1 rounded-full text-sm border">
{employer}
</span>
))}
</div>
</div>
)}
</section>
);
}
Where does this data come from? Most universities already collect it through alumni surveys, NACE First Destination surveys, and institutional research offices. The data exists — it's just not on the website. Your institutional research team probably has a spreadsheet. Get it.
Data Import: Getting 200 Programs Into the System
This is the part that scares enrollment teams. "We have 200 programs and the data is scattered across three systems." I get it. Here's the pragmatic approach:
Phase 1: CSV import (Week 1) Export whatever you have from your SIS (Banner, PeopleSoft, Workday Student). It'll be messy. You'll get program names, CIP codes, and degree levels. Import this as your skeleton.
Phase 2: Content enrichment (Week 1-2) Your marketing team writes or rewrites descriptions for the top 20 programs by enrollment. Use AI assistance for the other 180 to create first drafts, then have department chairs review. This is where most projects stall — don't let perfect be the enemy of published.
Phase 3: Career outcomes (Week 2) Pull data from your institutional research office, NACE surveys, and IPEDS completions data. Even if you only have salary data for 50 programs, launch with what you have. "Data not available" is fine for now — it creates internal pressure to fill the gaps.
Phase 4: Ongoing sync Set up a quarterly review process. New programs get added, discontinued programs get archived (301 redirect to the department page), tuition gets updated annually.
Performance and Accessibility Considerations
A program finder with 200 programs and a filter interface can get heavy if you're not careful.
- Server-side filtering: Don't load all 200 programs and filter client-side. Use server components with URL-based filters so the database does the work. First paint should be fast.
- Static generation: Use Next.js
generateStaticParamsto pre-render all 200 program detail pages at build time. They'll serve from the CDN edge. - Image optimization: Faculty headshots and campus photos should use
next/imagewith appropriate sizing. - Accessibility: Filter controls need proper labels and ARIA attributes. The program grid should use
role="list". Filter changes should announce results count to screen readers usingaria-live="polite". - Mobile-first: The filter sidebar should collapse to a bottom sheet or modal on mobile. Don't make users scroll past 8 filter groups to see results.
Target metrics: Largest Contentful Paint under 1.5s, Cumulative Layout Shift under 0.05, and INP under 150ms. These are achievable with the server component architecture described above.
Timeline and Cost
Here's what a realistic build looks like:
| Phase | Duration | Deliverables |
|---|---|---|
| Discovery & data audit | 2-3 days | Schema design, data gap analysis, content plan |
| Database setup & data import | 2-3 days | Supabase tables, CSV import scripts, initial data |
| Filter/search interface | 3-4 days | Program index page, filter sidebar, search, responsive design |
| Program detail pages | 3-4 days | Detail template, career outcomes widget, faculty links, structured data |
| SEO & sitemap | 1 day | XML sitemap, meta tags, JSON-LD, OG images |
| QA & launch | 1-2 days | Cross-browser testing, accessibility audit, performance optimization |
| Total | 1.5-2.5 weeks | Full program finder |
Cost: $8,000-$15,000 as a standalone add-on to an existing university website. If you're doing a full site rebuild with us, the program finder is included as part of the information architecture. Check our pricing page for current rates on university web projects.
The ROI calculation is straightforward. If the program finder converts just 5 additional students per year at an average lifetime value of $80,000, that's $400,000 in revenue against a one-time $8-15K build cost. The payback period is measured in weeks, not years.
If you're an enrollment director reading this and thinking "we need this yesterday," reach out. We've built these before and we can move fast.
FAQ
How long does it take to build a university program finder? For a university with 200 programs, expect 1.5 to 2.5 weeks from kickoff to launch. The biggest variable isn't the development — it's the data. If your program data is clean and structured in a CSV or accessible via your SIS API, the build goes fast. If we're scraping data from PDF catalogs or inconsistent CMS pages, add a few days for data cleanup.
Can a program finder integrate with our existing CMS like Drupal or WordPress? Yes, but the approach matters. We typically build the program finder as a standalone Next.js application that can be embedded in your existing site via an iframe, subdomain (programs.university.edu), or subfolder proxy. This avoids the limitations of your CMS's templating system while keeping the experience consistent. If you're considering a full migration to a headless CMS, we handle that through our headless CMS development practice.
What's the best database for a university program directory? For most universities, Supabase (managed Postgres) hits the sweet spot. You get relational data modeling for the structured parts (programs, departments, campuses), JSONB for semi-structured data (career outcomes, curriculum), full-text search, and a REST/GraphQL API without writing backend code. For universities with strict on-premises requirements, a self-hosted Postgres instance works identically — you just lose the managed API layer.
How do we get career outcomes data for our program pages? Start with three sources: your institutional research office (they likely run alumni surveys), NACE First Destination Survey data if your career center participates, and IPEDS completions data from the Department of Education. For salary data specifically, the Bureau of Labor Statistics Occupational Outlook Handbook maps to CIP codes, giving you national median salary data for every program's typical occupations. It's not university-specific, but it's better than nothing while you build up your own data.
Does a program finder really improve SEO for universities? Absolutely. Going from 1 programs page to 200 individual program pages means 200 unique URLs that can rank for specific program queries. Each page has a unique title tag, meta description, and structured data. We've seen universities gain 300-500% more organic traffic to program-related pages within 3-6 months of launching a program finder. The key is that each page targets a specific long-tail keyword like "master's in data analytics online at [university name]" rather than trying to rank one page for everything.
Should we build the program finder with Next.js or another framework? Next.js is our recommendation for most university program finders because of its hybrid rendering model — static generation for the 200 program detail pages (fast, cacheable, SEO-friendly) and server components for the dynamic filter/search interface. Astro is a strong alternative if your site is primarily content-driven with minimal interactivity. We work with both through our Next.js and Astro development practices.
How do we keep program data up to date after launch? The cleanest solution is a scheduled sync with your SIS. If your SIS has an API (Banner has Ethos, Workday has their REST API, PeopleSoft has Integration Broker), we set up a nightly or weekly sync job that pulls updated program data into Supabase. For universities without an SIS API, we set up a simple admin interface or Google Sheets integration where your registrar's office can update program data and it flows to the website automatically.
What's the difference between a program finder and a program page redesign? A program page redesign typically means making your existing CMS pages look better. A program finder is a fundamentally different architecture — structured data in a database, a search/filter interface, individual program pages generated from that data, and cross-linking between programs, faculty, and departments. The redesign approach hits a ceiling because your CMS wasn't designed for this. The program finder approach scales: add a new program to the database, and it automatically appears in search results, filter options, department pages, and the sitemap.
How much does a custom university program finder cost in 2025? As a standalone project added to an existing university website, expect $8,000-$15,000 depending on the number of programs, data complexity, and integration requirements. This includes the database schema, data import, filter/search interface, program detail pages, structured data, and SEO optimization. For context, many universities spend $50,000-$200,000 on full website redesigns that still end up with an alphabetical list of programs. The program finder alone often delivers more enrollment impact than the rest of the redesign combined.