Flex Engineers
4 min read

Building MediaKing: A Conversion-Focused Agency Site on Next.js 16

Next.jsReactTailwind CSSFramer MotionWeb DesignLead Generation Website

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 our team made 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

LayerChoiceWhy
FrameworkNext.js 16 (App Router)File-based routing per page (/about, /services, /projects, /contact), server-rendered metadata for SEO
UIReact 19Latest concurrent rendering, used with client components only where interactivity is needed
StylingTailwind CSS 4Utility-first, no separate CSS files to maintain per component
MotionFramer MotionScroll-triggered reveals across About and Home sections
IconsLucide React + React Icons (Feather set)Consistent icon language without shipping a custom icon font
FormsWeb3FormsServerless 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 our team made — 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, our team 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 our team builds 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 we bring to every client engagement, regardless of stack.