Table of Contents
The Challenge
Similar to other legacy platforms, NGO-4's web portal suffered from standard monolithic system degradation. Running on old WordPress hosting, the site suffered from slow loading speeds, high server overhead, and an accessible layout structure that did not satisfy modern WCAG 2.1 AA accessibility regulations.
The main challenge was converting this complex, media-heavy portal into an ultra-fast headless Next.js frontend, ensuring complete accessibility compliance for all users, including those using screen readers and keyboard-only navigation. Additionally, form handlers (contact submissions, program registrations) had to be securely isolated from the public frontend and routed to the WordPress database.
The requirement: Implement a secure, highly accessible (WCAG AA compliant) headless Next.js 14 frontend. Migrate legacy media assets into an optimized, responsive pipeline, and provide a secure contact form relay gateway.
Architecture & Solution
The system separates the content management workspace from the delivery network. Editors continue to use WordPress as a data entry database. Build pipelines query the API and build static files served globally via a fast Edge CDN.
Modular Headless Segments
| Segment | Technology | Responsibility |
|---|---|---|
| CMS Workspace | WordPress Admin | Content editing, taxonomies, and layout administration. |
| API Middleware | Hono API Gateway | CORS validation, input validation, and secure form relays. |
| Edge Presentation | Next.js + CSS Modules | Fully static, accessible HTML render paths with instant hydration. |
Tech Stack
| Layer | Technology | Role |
|---|---|---|
| Frontend Framework | Next.js 14 | Edge-rendered pages, SSR fallback routes, and localized routing. |
| API Middleware | Hono | Lightweight HTTP gateway and input validation handler. |
| Styling | Vanilla CSS Modules | Completely scoped layout rules with zero CSS bloat. |
| Accessibility Tools | Axe Core & ESLint | Automatic WCAG 2.1 accessibility auditing. |
| Data Fetching | Axios Client | Typed API fetch wrappers. |
| Deployment | Docker & PM2 | Containerized local testing and VPS process supervision. |
Key Engineering Decisions
1. Hono-Secured Form Relay Gateway
To prevent bots from directly abusing legacy WordPress endpoint resources, we introduced a Hono API proxy. The proxy validates user requests with Zod schemas before transmitting the data to WordPress.
// src/api/forms.ts
import { Hono } from 'hono';
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
message: z.string().min(10).max(1000),
});
const app = new Hono();
app.post('/contact-relay', async (c) => {
const body = await c.req.json();
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return c.json({ success: false, errors: parsed.error.format() }, 422);
}
// Forward to WordPress Contact Form endpoint safely
const response = await fetch(`${process.env.WP_API_URL}/contact-form-7/v1/contact-forms/1/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed.data),
});
return c.json({ success: response.ok });
});
export default app;2. Accessible (a11y) Keyboard-Trapped Hamburger Menu
We built a custom React drawer component that satisfies strict WCAG AA rules. It captures keyboard focus when open and closes smoothly with the Escape key.
// src/components/common/NavbarDrawer.tsx
import React, { useEffect, useRef } from 'react';
export function NavbarDrawer({ isOpen, onClose, children }: { isOpen: boolean; onClose: () => void; children: React.ReactNode }) {
const drawerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
// Handle focus trapping inside drawerRef
if (e.key === 'Tab') {
const focusables = drawerRef.current?.querySelectorAll('a, button') || [];
const first = focusables[0] as HTMLElement;
const last = focusables[focusables.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
return (
<div ref={drawerRef} role="dialog" aria-modal="true" aria-hidden={!isOpen} className="drawer">
{children}
</div>
);
}3. Progressive Image Srcset Migration
WordPress posts often contain raw <img> tags pointing to absolute URLs. We designed an isomorphic parser that intercepts these tags and serves them via highly responsive WebP sources.
// src/shared/utils/image-srcset.ts
export function parseWordPressHtmlImages(htmlContent: string): string {
// Replace legacy img sources with responsive source attributes
return htmlContent.replace(
/<img([\s\S]*?)src="https:\/\/wp-domain\.org\/wp-content\/uploads\/(.*?)"([\s\S]*?)>/gi,
(match, before, filePath, after) => {
const optimizedUrl = `https://cdn.ngo4.org/uploads/${filePath}`;
return `<img ${before} src="${optimizedUrl}" srcset="${optimizedUrl}?w=400 400w, ${optimizedUrl}?w=800 800w" sizes="(max-width: 600px) 480px, 800px" ${after} loading="lazy" />`;
}
);
}4. Zero-Collision Layout Styles
We adopted strict CSS Modules. This eliminates the risk of styling rules leaking between components and maintains perfect rendering speeds.
/* src/components/projects/ProjectCard.module.css */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-card);
padding: var(--spacing-grid);
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.card:hover {
transform: translateY(-4px);
border-color: var(--color-accent);
}Deployment
Quick Start
Launch Next.js development server:
# Set environment variables
cp .env.example .env.local
# Install pnpm workspace dependencies
pnpm install
# Start the dev build script
pnpm devLocal Dev Ports
| Component | Port |
|---|---|
| Next.js Web Server | PORT_WEB (3005) |
| Hono API Proxy | PORT_API (4000) |
Architecture Feedback
Spotted a potential optimization or antipattern? Let me know.