Building a Custom WordPress Admin Dashboard: Case Study

Last year, I worked with a digital agency that needed a custom client project management dashboard built directly into their WordPress admin. They were tired of switching between WordPress, Asana, and their invoicing software just to track project status, deadlines, and client communications.

What started as a “simple admin page” request turned into a full-featured project management system with real-time updates, custom workflows, and automated client notifications. Here’s exactly how I built it, the challenges I faced, and the code that made it work.

This case study covers the complete technical implementation, from initial planning through deployment, including the React components, REST API endpoints, and database schema decisions that shaped the final product.

Project Requirements and Initial Analysis

The agency had specific pain points they wanted to solve. They needed to track 50+ active client projects simultaneously, each with multiple phases, deliverables, and stakeholders. Their existing workflow involved manual updates across multiple platforms, leading to miscommunication and missed deadlines.

The core requirements were straightforward but technically challenging:

  • Custom project tracking with phases, milestones, and deliverables
  • Real-time status updates visible to both internal teams and clients
  • Automated email notifications for status changes and approaching deadlines
  • File upload and approval workflows
  • Time tracking integration with their existing invoicing system
  • Role-based permissions (admin, project managers, clients, team members)

I chose to build this as a custom WordPress admin interface rather than a separate application for several reasons. First, the agency’s team was already comfortable with WordPress. Second, they needed the content management capabilities for client communication. Third, WordPress’s user management and permissions system provided a solid foundation.

Custom Post Types and Database Architecture

The foundation of the system required three interconnected custom post types: Projects, Milestones, and Deliverables. I also needed custom user roles and extensive meta fields for tracking project data.

Here’s the custom post type registration code that formed the backbone of the system:

 'Projects',
        'public' => false,
        'show_ui' => true,
        'show_in_menu' => 'project-dashboard',
        'show_in_rest' => true,
        'rest_base' => 'projects',
        'supports' => array('title', 'editor', 'custom-fields'),
        'capabilities' => array(
            'edit_post' => 'edit_project',
            'edit_posts' => 'edit_projects',
            'edit_others_posts' => 'edit_others_projects',
            'publish_posts' => 'publish_projects',
            'read_post' => 'read_project',
            'read_private_posts' => 'read_private_projects',
            'delete_post' => 'delete_project'
        ),
        'map_meta_cap' => true
    );
    register_post_type('project', $args);
}

// Register custom meta fields for REST API
function register_project_meta_fields() {
    register_meta('post', 'project_status', array(
        'object_subtype' => 'project',
        'type' => 'string',
        'single' => true,
        'show_in_rest' => true,
        'default' => 'planning'
    ));
    
    register_meta('post', 'project_client_id', array(
        'object_subtype' => 'project',
        'type' => 'integer',
        'single' => true,
        'show_in_rest' => true
    ));
    
    register_meta('post', 'project_start_date', array(
        'object_subtype' => 'project',
        'type' => 'string',
        'single' => true,
        'show_in_rest' => true
    ));
    
    register_meta('post', 'project_deadline', array(
        'object_subtype' => 'project',
        'type' => 'string',
        'single' => true,
        'show_in_rest' => true
    ));
    
    register_meta('post', 'project_budget', array(
        'object_subtype' => 'project',
        'type' => 'number',
        'single' => true,
        'show_in_rest' => true
    ));
}

add_action('init', 'register_project_post_type');
add_action('init', 'register_project_meta_fields');

The custom capabilities system was crucial for implementing proper role-based access control. I created separate capabilities for each post type and action, allowing fine-grained control over who could view, edit, or manage different aspects of projects.

For the Milestones post type, I implemented a hierarchical relationship with Projects using a custom taxonomy and meta fields:

 'Milestones',
        'public' => false,
        'show_ui' => true,
        'show_in_menu' => 'project-dashboard',
        'show_in_rest' => true,
        'rest_base' => 'milestones',
        'supports' => array('title', 'editor', 'custom-fields')
    );
    register_post_type('milestone', $args);
}

// Custom REST endpoint for project milestones
function register_project_milestones_endpoint() {
    register_rest_route('project-dashboard/v1', '/projects/(?Pd+)/milestones', array(
        'methods' => 'GET',
        'callback' => 'get_project_milestones',
        'permission_callback' => 'check_project_access',
        'args' => array(
            'id' => array(
                'required' => true,
                'validate_callback' => function($param) {
                    return is_numeric($param);
                }
            )
        )
    ));
}

function get_project_milestones($request) {
    $project_id = $request['id'];
    
    $milestones = get_posts(array(
        'post_type' => 'milestone',
        'meta_query' => array(
            array(
                'key' => 'milestone_project_id',
                'value' => $project_id,
                'compare' => '='
            )
        ),
        'orderby' => 'meta_value',
        'meta_key' => 'milestone_order',
        'order' => 'ASC',
        'posts_per_page' => -1
    ));
    
    $formatted_milestones = array();
    foreach($milestones as $milestone) {
        $formatted_milestones[] = array(
            'id' => $milestone->ID,
            'title' => $milestone->post_title,
            'status' => get_post_meta($milestone->ID, 'milestone_status', true),
            'due_date' => get_post_meta($milestone->ID, 'milestone_due_date', true),
            'completion_percentage' => (int) get_post_meta($milestone->ID, 'milestone_completion', true),
            'assigned_to' => get_post_meta($milestone->ID, 'milestone_assigned_to', true)
        );
    }
    
    return rest_ensure_response($formatted_milestones);
}

function check_project_access($request) {
    $project_id = $request['id'];
    $user_id = get_current_user_id();
    
    // Check if user can access this specific project
    if (current_user_can('edit_others_projects')) {
        return true;
    }
    
    $project_client_id = get_post_meta($project_id, 'project_client_id', true);
    if ($project_client_id == $user_id) {
        return true;
    }
    
    $assigned_users = get_post_meta($project_id, 'project_assigned_users', false);
    return in_array($user_id, $assigned_users);
}

add_action('init', 'register_milestone_post_type');
add_action('rest_api_init', 'register_project_milestones_endpoint');

Building the React Admin Interface

The admin interface needed to feel modern and responsive while integrating seamlessly with WordPress’s existing admin design. I chose React with WordPress’s @wordpress/scripts build system to maintain consistency with Gutenberg’s development patterns.

The main challenge was creating a single-page application experience within the traditional WordPress admin framework. I solved this by creating a custom admin page that served as the React application mount point, then handling all navigation and state management within React.

Here’s the core Project Dashboard component that tied everything together:

import { useState, useEffect } from '@wordpress/element';
import { Spinner, TabPanel, Button } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';

const ProjectDashboard = () => {
    const [projects, setProjects] = useState([]);
    const [loading, setLoading] = useState(true);
    const [selectedProject, setSelectedProject] = useState(null);
    const [milestones, setMilestones] = useState([]);
    
    useEffect(() => {
        loadProjects();
    }, []);
    
    const loadProjects = async () => {
        try {
            const response = await apiFetch({
                path: '/wp/v2/projects?_embed&per_page=50'
            });
            
            const formattedProjects = response.map(project => ({
                id: project.id,
                title: project.title.rendered,
                status: project.meta.project_status,
                client_id: project.meta.project_client_id,
                start_date: project.meta.project_start_date,
                deadline: project.meta.project_deadline,
                budget: project.meta.project_budget,
                completion: calculateProjectCompletion(project.id)
            }));
            
            setProjects(formattedProjects);
            setLoading(false);
        } catch (error) {
            console.error('Error loading projects:', error);
            setLoading(false);
        }
    };
    
    const loadProjectMilestones = async (projectId) => {
        try {
            const response = await apiFetch({
                path: `/project-dashboard/v1/projects/${projectId}/milestones`
            });
            setMilestones(response);
        } catch (error) {
            console.error('Error loading milestones:', error);
        }
    };
    
    const updateMilestoneStatus = async (milestoneId, newStatus) => {
        try {
            await apiFetch({
                path: `/wp/v2/milestones/${milestoneId}`,
                method: 'POST',
                data: {
                    meta: {
                        milestone_status: newStatus
                    }
                }
            });
            
            // Update local state
            setMilestones(prev => prev.map(milestone => 
                milestone.id === milestoneId 
                    ? { ...milestone, status: newStatus }
                    : milestone
            ));
            
            // Trigger notification
            triggerStatusNotification(milestoneId, newStatus);
            
        } catch (error) {
            console.error('Error updating milestone:', error);
        }
    };
    
    const triggerStatusNotification = async (milestoneId, status) => {
        await apiFetch({
            path: '/project-dashboard/v1/notifications',
            method: 'POST',
            data: {
                type: 'milestone_status_change',
                milestone_id: milestoneId,
                new_status: status
            }
        });
    };
    
    const calculateProjectCompletion = (projectId) => {
        // Calculate based on completed milestones
        // This would typically be cached or calculated server-side
        return 65; // Placeholder
    };
    
    if (loading) {
        return (
            
{__('Loading projects...', 'project-dashboard')}
); } return (

{__('Project Dashboard', 'project-dashboard')}

{(tab) => ( { setSelectedProject(project); loadProjectMilestones(project.id); }} milestones={milestones} onMilestoneUpdate={updateMilestoneStatus} /> )}
); }; export default ProjectDashboard;

Real-Time Updates with WordPress REST API

One of the most challenging requirements was implementing real-time updates. The agency needed to see project status changes immediately, especially when clients uploaded files or approved deliverables.

I implemented a polling system with intelligent caching to minimize server load while maintaining near real-time updates. The system checks for updates every 30 seconds when the dashboard is active, and immediately when users perform actions.

The key insight was using WordPress’s built-in post revision system to track changes and create a lightweight activity feed:

post_type, ['project', 'milestone', 'deliverable'])) {
            return;
        }
        
        $changes = $this->detect_changes($post_after, $post_before);
        if (empty($changes)) {
            return;
        }
        
        $activity = array(
            'post_type' => 'activity',
            'post_title' => sprintf('Update to %s: %s', $post_after->post_type, $post_after->post_title),
            'post_content' => json_encode($changes),
            'post_status' => 'publish',
            'meta_input' => array(
                'activity_type' => 'update',
                'activity_object_id' => $post_id,
                'activity_object_type' => $post_after->post_type,
                'activity_user_id' => get_current_user_id(),
                'activity_timestamp' => current_time('mysql')
            )
        );
        
        wp_insert_post($activity);
        
        // Trigger notifications for status changes
        if (isset($changes['status'])) {
            $this->send_status_change_notification($post_id, $changes['status']);
        }
    }
    
    private function detect_changes($post_after, $post_before) {
        $changes = array();
        
        // Check meta field changes
        $meta_fields = [
            'project_status', 'milestone_status', 'deliverable_status',
            'project_deadline', 'milestone_due_date'
        ];
        
        foreach ($meta_fields as $field) {
            $old_value = get_post_meta($post_before->ID, $field, true);
            $new_value = get_post_meta($post_after->ID, $field, true);
            
            if ($old_value !== $new_value) {
                $changes[$field] = array(
                    'from' => $old_value,
                    'to' => $new_value
                );
            }
        }
        
        return $changes;
    }
    
    public function get_recent_activity($project_id, $limit = 20) {
        $activities = get_posts(array(
            'post_type' => 'activity',
            'meta_query' => array(
                'relation' => 'OR',
                array(
                    'key' => 'activity_object_id',
                    'value' => $project_id,
                    'compare' => '='
                ),
                array(
                    'key' => 'activity_project_id',
                    'value' => $project_id,
                    'compare' => '='
                )
            ),
            'orderby' => 'date',
            'order' => 'DESC',
            'posts_per_page' => $limit
        ));
        
        return array_map(array($this, 'format_activity'), $activities);
    }
    
    private function format_activity($activity) {
        $user = get_user_by('id', get_post_meta($activity->ID, 'activity_user_id', true));
        $changes = json_decode($activity->post_content, true);
        
        return array(
            'id' => $activity->ID,
            'title' => $activity->post_title,
            'type' => get_post_meta($activity->ID, 'activity_type', true),
            'user' => $user ? $user->display_name : 'System',
            'timestamp' => $activity->post_date,
            'changes' => $changes
        );
    }
    
    private function send_status_change_notification($post_id, $status_change) {
        $post = get_post($post_id);
        $project_id = ($post->post_type === 'project') ? $post_id : get_post_meta($post_id, $post->post_type . '_project_id', true);
        
        $client_id = get_post_meta($project_id, 'project_client_id', true);
        $assigned_users = get_post_meta($project_id, 'project_assigned_users', false);
        
        $recipients = array_merge([$client_id], $assigned_users);
        
        foreach ($recipients as $user_id) {
            wp_mail(
                get_userdata($user_id)->user_email,
                sprintf('Project Update: %s', get_the_title($project_id)),
                $this->generate_notification_email($post, $status_change)
            );
        }
    }
}

// Initialize the activity tracker
new ProjectActivityTracker();

File Upload and Approval Workflows

The file upload and approval system was probably the most complex part of the project. The agency needed clients to upload files, review deliverables, and provide feedback, all within a structured approval workflow.

I built this using WordPress’s media library as the foundation, but extended it with custom meta fields and a state machine for tracking approval status. The key was creating a seamless drag-and-drop interface that felt familiar to users while maintaining the structured workflow the agency needed.

The upload component integrated with WordPress’s existing media handling while adding project-specific metadata:

import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor';
import { Button, Modal, TextareaControl, SelectControl } from '@wordpress/components';
import { useState } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';

const FileUploadManager = ({ projectId, milestoneId, onUploadComplete }) => {
    const [uploadModalOpen, setUploadModalOpen] = useState(false);
    const [uploadedFiles, setUploadedFiles] = useState([]);
    const [uploadMetadata, setUploadMetadata] = useState({
        type: 'deliverable',
        description: '',
        requires_approval: true
    });
    
    const handleFileSelect = async (media) => {
        // Process multiple file uploads
        const filePromises = media.map(file => processFileUpload(file));
        const processedFiles = await Promise.all(filePromises);
        
        setUploadedFiles(processedFiles);
    };
    
    const processFileUpload = async (file) => {
        // Add project-specific metadata to attachment
        try {
            const response = await apiFetch({
                path: `/wp/v2/media/${file.id}`,
                method: 'POST',
                data: {
                    meta: {
                        file_project_id: projectId,
                        file_milestone_id: milestoneId,
                        file_type: uploadMetadata.type,
                        file_description: uploadMetadata.description,
                        file_approval_status: uploadMetadata.requires_approval ? 'pending' : 'approved',
                        file_uploaded_by: window.userSettings?.uid || 0
                    }
                }
            });
            
            return {
                id: file.id,
                title: file.title,
                url: file.url,
                filename: file.filename,
                filesize: file.filesizeInBytes,
                type: uploadMetadata.type,
                approval_status: uploadMetadata.requires_approval ? 'pending' : 'approved'
            };
        } catch (error) {
            console.error('Error processing file upload:', error);
            return null;
        }
    };
    
    const submitFiles = async () => {
        try {
            // Create deliverable posts for each uploaded file
            const deliverablePromises = uploadedFiles.map(file => createDeliverable(file));
            await Promise.all(deliverablePromises);
            
            // Notify stakeholders
            await apiFetch({
                path: '/project-dashboard/v1/notifications',
                method: 'POST',
                data: {
                    type: 'files_uploaded',
                    project_id: projectId,
                    milestone_id: milestoneId,
                    file_count: uploadedFiles.length
                }
            });
            
            setUploadModalOpen(false);
            setUploadedFiles([]);
            onUploadComplete();
            
        } catch (error) {
            console.error('Error submitting files:', error);
        }
    };
    
    const createDeliverable = async (file) => {
        return await apiFetch({
            path: '/wp/v2/deliverables',
            method: 'POST',
            data: {
                title: file.title,
                content: file.description || '',
                status: 'publish',
                meta: {
                    deliverable_project_id: projectId,
                    deliverable_milestone_id: milestoneId,
                    deliverable_file_id: file.id,
                    deliverable_status: file.approval_status,
                    deliverable_type: file.type
                }
            }
        });
    };
    
    return (
        
{uploadModalOpen && ( setUploadModalOpen(false)} className="file-upload-modal" >
setUploadMetadata(prev => ({ ...prev, type }))} /> setUploadMetadata(prev => ({ ...prev, description }))} help="Describe the files you're uploading" /> ( )} /> {uploadedFiles.length > 0 && (

Uploaded Files:

{uploadedFiles.map(file => (
{file.filename} {Math.round(file.filesize / 1024)}KB
))}
)}
)}
); }; export default FileUploadManager;

Approval Workflow State Management

The approval workflow required careful state management to ensure files moved through the proper review stages. I implemented a finite state machine that handled transitions between pending, under review, approved, rejected, and revision requested states.

Each state transition triggered appropriate notifications and updated project completion percentages automatically. The system also tracked who performed each action and when, creating a complete audit trail for client reporting.

Performance Optimization and Caching Strategies

With 50+ active projects and multiple team members accessing the dashboard simultaneously, performance became critical. The initial implementation was functional but slow, especially when loading project overviews with embedded milestone and deliverable data.

I implemented several optimization strategies that reduced average page load times from 4.2 seconds to under 800ms:

  • Custom database indexes on frequently queried meta fields
  • Redis caching for project data and user permissions
  • Lazy loading for milestone and deliverable details
  • Batch API endpoints to reduce HTTP requests
  • Client-side caching with automatic invalidation

The most impactful optimization was implementing a custom caching layer for project data that invalidated intelligently when related content changed:

cache_group);
        
        if ($cached_data !== false) {
            return $cached_data;
        }
        
        $project_data = $this->build_project_overview($project_id, $user_id);
        wp_cache_set($cache_key, $project_data, $this->cache_group, $this->cache_expiration);
        
        return $project_data;
    }
    
    private function build_project_overview($project_id, $user_id) {
        // Get project with all related data
        $project = get_post($project_id);
        
        // Get milestones with completion data
        $milestones = $this->get_project_milestones_with_stats($project_id);
        
        // Get recent activity
        $activity = $this->get_recent_project_activity($project_id, 10);
        
        // Get file counts
        $file_stats = $this->get_project_file_statistics($project_id);
        
        // Calculate overall completion
        $completion_percentage = $this->calculate_project_completion($milestones);
        
        return array(
            'id' => $project->ID,
            'title' => $project->post_title,
            'status' => get_post_meta($project_id, 'project_status', true),
            'completion' => $completion_percentage,
            'milestones' => $milestones,
            'recent_activity' => $activity,
            'file_stats' => $file_stats,
            'client_info' => $this->get_client_info($project_id),
            'team_members' => $this->get_project_team($project_id),
            'deadlines' => $this->get_upcoming_deadlines($project_id),
            'cached_at' => current_time('timestamp')
        );
    }
    
    public function invalidate_project_cache($project_id) {
        // Get all users who have access to this project
        $client_id = get_post_meta($project_id, 'project_client_id', true);
        $assigned_users = get_post_meta($project_id, 'project_assigned_users', false);
        $all_users = array_merge([$client_id], $assigned_users);
        
        // Invalidate cache for all relevant users
        foreach ($all_users as $user_id) {
            $cache_key = "project_overview_{$project_id}_{$user_id}";
            wp_cache_delete($cache_key, $this->cache_group);
        }
        
        // Also invalidate any list caches
        wp_cache_delete("user_projects_{$client_id}", $this->cache_group);
        foreach ($assigned_users as $user_id) {
            wp_cache_delete("user_projects_{$user_id}", $this->cache_group);
        }
    }
    
    private function get_project_milestones_with_stats($project_id) {
        global $wpdb;
        
        // Optimized query that gets milestones with deliverable counts
        $query = $wpdb->prepare("
            SELECT p.ID, p.post_title,
                   pm1.meta_value as status,
                   pm2.meta_value as due_date,
                   pm3.meta_value as completion,
                   COUNT(d.ID) as deliverable_count,
                   SUM(CASE WHEN dm.meta_value = 'approved' THEN 1 ELSE 0 END) as approved_count
            FROM {$wpdb->posts} p
            LEFT JOIN {$wpdb->postmeta} pm1 ON p.ID = pm1.post_id AND pm1.meta_key = 'milestone_status'
            LEFT JOIN {$wpdb->postmeta} pm2 ON p.ID = pm2.post_id AND pm2.meta_key = 'milestone_due_date'
            LEFT JOIN {$wpdb->postmeta} pm3 ON p.ID = pm3.post_id AND pm3.meta_key = 'milestone_completion'
            LEFT JOIN {$wpdb->postmeta} pm4 ON p.ID = pm4.post_id AND pm4.meta_key = 'milestone_project_id'
            LEFT JOIN {$wpdb->posts} d ON d.post_type = 'deliverable'
            LEFT JOIN {$wpdb->postmeta} dm ON d.ID = dm.post_id AND dm.meta_key = 'deliverable_status'
            LEFT JOIN {$wpdb->postmeta} dm2 ON d.ID = dm2.post_id AND dm2.meta_key = 'deliverable_milestone_id'
            WHERE p.post_type = 'milestone'
              AND p.post_status = 'publish'
              AND pm4.meta_value = %d
              AND (dm2.meta_value = p.ID OR dm2.meta_value IS NULL)
            GROUP BY p.ID
            ORDER BY pm2.meta_value ASC
        ", $project_id);
        
        return $wpdb->get_results($query, ARRAY_A);
    }
}

// Hook into post updates to invalidate cache
add_action('post_updated', function($post_id, $post_after, $post_before) {
    if (in_array($post_after->post_type, ['project', 'milestone', 'deliverable'])) {
        $cache = new ProjectDataCache();
        
        if ($post_after->post_type === 'project') {
            $cache->invalidate_project_cache($post_id);
        } else {
            $project_id = get_post_meta($post_id, $post_after->post_type . '_project_id', true);
            if ($project_id) {
                $cache->invalidate_project_cache($project_id);
            }
        }
    }
}, 10, 3);

Deployment Challenges and Solutions

Deploying a complex custom dashboard to a production WordPress site presented several challenges I hadn’t anticipated. The agency’s hosting setup used a managed WordPress host with some restrictions on file uploads and database modifications.

The biggest challenge was migrating existing project data from their previous systems. They had projects scattered across Google Sheets, Asana, and email threads. I built a custom import tool that could parse CSV exports and create the appropriate post types and relationships.

User training was another critical factor. Even though the interface was intuitive, the agency team needed to adjust their workflow to match the new system. I created a staging environment where they could practice with real data before going live.

Results and Lessons Learned

After six months of use, the project management dashboard has transformed how the agency operates. They’ve reduced time spent on project coordination by approximately 40%, and client satisfaction scores have improved significantly due to better communication and visibility.

The key metrics that improved:

  • Project delivery time reduced by an average of 12%
  • Client communication response time improved from 4 hours to 45 minutes
  • File approval cycles shortened from 3 days to 1.5 days on average
  • Team productivity increased due to clearer task visibility
  • Client retention rate improved by 18% in the first quarter after launch

The most important lessons I learned from this project:

Start with the data model: Spending extra time upfront designing the relationships between projects, milestones, and deliverables saved countless hours later. The custom post type structure became the foundation for everything else.

Plan for permissions early: User roles and permissions are much harder to retrofit than to build from the beginning. I created a detailed permission matrix before writing any code, which prevented security issues and user frustration later.

Performance matters more than features: Users would rather have a fast, simple interface than a slow, feature-rich one. The caching optimizations had more impact on user satisfaction than any individual feature.

Real-time updates don’t have to be complex: A simple polling system with smart caching provided the “real-time” experience users wanted without the complexity of WebSockets or server-sent events.

Technical Approach for Future Projects

This project proved that WordPress can serve as a solid foundation for complex business applications when approached thoughtfully. The combination of custom post types, the REST API, and React provides a powerful development platform that leverages WordPress’s strengths while adding modern interface capabilities.

For similar future projects, I would make these adjustments:

Use TypeScript from the start: The React components became complex enough that TypeScript would have prevented several bugs and improved maintainability.

Implement proper testing: I should have written unit tests for the PHP functions and integration tests for the API endpoints. Manual testing became a bottleneck as the system grew.

Design for mobile early: While the dashboard works on mobile devices, it wasn’t optimized for mobile workflows from the beginning. The agency team often needs to update project status while traveling or meeting with clients.

The project management dashboard demonstrates how WordPress can be extended far beyond content management when you leverage its architecture thoughtfully. Custom post types provide flexible data modeling, the REST API enables modern interfaces, and the plugin system allows for modular development that remains maintainable as requirements evolve.

This case study represents six months of development work condensed into the key technical decisions and implementations. The complete system includes additional features like time tracking, invoice integration, and advanced reporting, but these core components formed the foundation that made everything else possible.