The brief: a portfolio site that has to sell
MediaKing started as a template for creative professionals — video editors, designers, social media managers, freelance agencies — but the real design constraint wasn't visual, it was commercial. A portfolio site for a creative agency has one job: turn a scroll into an inquiry. Every section on the site — Home, About, Services, Projects, Contact — had to earn its place by moving a visitor closer to booking a consultation, not just showing off work.
That constraint shaped almost every technical decision in the build, from how pricing is presented on the Services page to why the hero video autoplays muted and only unmutes on the visitor's first click.
Stack
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 16 (App Router) | File-based routing per page (/about, /services, /projects, /contact), server-rendered metadata for SEO |
| UI | React 19 | Latest concurrent rendering, used with client components only where interactivity is needed |
| Styling | Tailwind CSS 4 | Utility-first, no separate CSS files to maintain per component |
| Motion | Framer Motion | Scroll-triggered reveals across About and Home sections |
| Icons | Lucide React + React Icons (Feather set) | Consistent icon language without shipping a custom icon font |
| Forms | Web3Forms | Serverless contact form submission — no backend to host or maintain |
Notably absent: a CMS, a database, or a custom API route. Every page is static content defined directly in TypeScript arrays inside the component files. For a template meant to be forked and rebranded by non-technical agency owners, that's a deliberate trade-off — editing a project title or a service price means editing a typed array, not learning a CMS dashboard or dealing with a database connection string.
Architecture: content as typed data, not prose
Rather than storing copy in markdown or a headless CMS, each page's content lives as a typed array right next to the component that renders it. The Services page is a good example:
const services = [
{
title: "Video Editing",
description: "...",
features: [
{ label: "Short-Form Reels & TikToks", icon: FiVideo },
{ label: "Commercial & Promo Videos", icon: FiScissors },
{ label: "Advanced Color Grading & Audio", icon: FiSliders },
],
price: "$200",
image: "/services/03. Video Editing.jpg",
},
// ...
];
The component then just maps over the array. This keeps ServiceCards.tsx under 150 lines despite rendering seven fully custom, image-paired pricing cards, and it means adding an eighth service is a copy-paste-edit operation with full type safety — TypeScript catches a missing price or malformed icon import at compile time, before it ever reaches production.
The same pattern drives the Projects page, but there it had to handle a real content wrinkle: the portfolio mixes video reels (hosted on Vimeo) with static image work (graphic design, branding, code projects). Rather than force everything into one shape, I modeled it as a discriminated union:
type ImageProject = {
type: "image";
title: string;
category: string;
image: string;
link?: string;
};
type VideoProject = {
type: "video";
title: string;
category: string;
description: string;
metric: string;
videoSrc: string;
};
type Project = ImageProject | VideoProject;
The grid component then switches on project.type and renders an embedded Vimeo iframe for videos (complete with a view-count metric badge like "↑ 2.4M Views") or an optimized next/image with an optional GitHub link for code work. Category filtering ("All", "Video Editing", "Graphic Design", "Development", "Branding") is a single useState and an array filter — no routing, no query params, because the interaction is cheap enough not to need URL state.
The hero video: autoplay without breaking the browser
The homepage hero embeds a full-bleed Vimeo player that autoplays on load. Browsers block autoplay-with-sound almost universally, so the honest approach is to autoplay muted and give the visitor an easy path to sound — without a jarring "click to unmute" button competing with the CTA.
useEffect(() => {
const unmute = () => {
iframeRef.current?.contentWindow?.postMessage(
'{"method":"setVolume","value":1}',
"*"
);
iframeRef.current?.contentWindow?.postMessage(
'{"method":"setMuted","value":false}',
"*"
);
document.removeEventListener("click", unmute);
};
document.addEventListener("click", unmute);
return () => document.removeEventListener("click", unmute);
}, []);
The first click anywhere on the page — not necessarily on the video — unmutes it via Vimeo's postMessage API, then the listener removes itself. It's a small piece of logic, but it's the difference between a hero section that feels broken (silent video, no obvious way to fix it) and one that feels intentional.
Lead capture without a backend
The Contact page form posts directly to Web3Forms' API from the client:
const res = await fetch("https://api.web3forms.com/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
access_key: WEB3FORMS_KEY,
name: formData.name,
email: formData.email,
message: formData.message,
subject: `New contact from ${formData.name}`,
}),
});
For a template meant to be deployed by freelancers and small agencies, standing up a mail server or a serverless function just to relay a contact form is unnecessary infrastructure. Web3Forms handles spam filtering and email delivery, and the access key is read from NEXT_PUBLIC_WEB3FORMS_KEY so each deployment just needs its own environment variable — no code change required to rebrand and redeploy. The form has three explicit states (idle, sending, sent, error) rendered directly in the component, so a visitor always sees exactly what's happening — including a distinct "Message Sent!" confirmation view that replaces the form entirely rather than just flashing a toast.
What this project demonstrates
MediaKing is a small codebase, but it's built the way I build production sites: content modeled as typed data so non-engineers can safely edit it, discriminated unions instead of optional-field soup when a list mixes genuinely different shapes, and third-party integrations (Vimeo, Web3Forms) wired in with just enough client-side logic to feel native — no more, no less. Nothing here is over-engineered with a CMS or database it doesn't need, and nothing is under-engineered to the point where adding a ninth service or a new project category means restructuring the app. That balance — matching the engineering to what the project actually requires — is the same judgment call I bring to client work regardless of stack.