Flex Engineers
4 min read

Building ApexDv: A Multi-Portfolio Agency Site Without a CMS

Next.jsReactTailwind CSSComponent ArchitectureWeb3Custom Web Design

The brief

ApexDv is a blockchain and NFT development agency that needed a portfolio site doing double duty: sell the agency's services (NFT development, smart contracts, metaverse builds) and prove credibility through detailed case studies of real client work — CoinZoom, Luna Market, Enjin, ParaSwap, Horizen, and a dozen others. That second requirement is the one that shapes everything about how this codebase is built. A typical agency site has three or four portfolio pieces with a shared template. This one needed each case study to look and feel bespoke — different hero treatments, different result sections, different partner/review layouts — while still shipping from a single Next.js codebase without a CMS in sight.

Why no CMS

The obvious answer for "dozen+ content-heavy pages" is Contentful, Sanity, or a headless WordPress backend. Our team deliberately skipped that. The case studies aren't going to change week to week — they're closed engagements — and every CMS integration trades a chunk of build complexity and a recurring API dependency for content-editing convenience nobody was going to use. Instead, each portfolio page is a plain Next.js page component that composes purpose-built section components:

pages/portfolio-coinzoom.js
pages/portfolio-luna.js
pages/portfolio-enjin.js
pages/portfolio-paraswap.js
...
components/PortfolioDetails/CoinzoomGalaxyDetails/
components/PortfolioDetails/LunaDetails/
components/PortfolioDetails/EnjinDetails/
components/PortfolioDetails/ParaswapDetails/

Each detail folder holds a hero, a "next steps"/results section, and (where relevant) a partners or client-review block. That's more files than a generic [slug].js + CMS-driven template would produce, but it buys real design freedom per case study — the ParaSwap page's results section doesn't have to compromise to fit the same shape as Enjin's trusted-partners section — and it means every page is statically known at build time, so Next.js can pre-render all of them with zero runtime data fetching.

The tabbed technology showcase

The one piece of genuinely interactive UI on the marketing side is the "Technologies we work with" section on the homepage — a four-tab interface (NFT Development, Front End, Blockchain, Smart Contract Development) where switching tabs swaps out an illustration, a paragraph, and a list of five feature cards, all driven by a single state variable:

function Technologies() {
  const [activeTech, setActiveTech] = useState(1);

  const techData = [
    { id: 1, name: "NFT Development", img: "...", data: [...] },
    { id: 2, name: "Front End", img: "...", data: [...] },
    { id: 3, name: "Blockchain", img: "...", data: [...] },
    { id: 4, name: "Smart Contract Development", img: "...", data: [...] },
  ];

  return (
    <div className="container p-5">
      {/* active tab's hero image + description */}
      <Image src={techData.find((t) => t.id === activeTech).img} ... />
      {/* tab list + feature cards for the active tab */}
    </div>
  );
}

It's a deliberately simple pattern — one array of tab configs, one find() lookup, no external state library — but it's the kind of component that's easy to over-engineer into a generic "tabs" abstraction with render props and context. Our team kept it as a single component because there's exactly one place in the app that needs this behavior, and the array-of-objects shape means adding a fifth tab is a data change, not a code change.

Design system via Tailwind, not a component library

The stack is Next.js 12 (Pages Router) with React 18, Tailwind CSS for utility styling, and Material-UI available for anything that needed pre-built primitives. Rather than lean on MUI's component styling, most of the UI — buttons, cards, form fields, the animated underline on the active tab — is hand-built with Tailwind utility classes and a few custom keyframe animations defined directly in the Tailwind config:

// tailwind.config.js
keyframes: {
  leftToRight: { '0%': { left: '0px', opacity: '0' }, '100%': { left: '200px', opacity: '1' } },
  slide: { '0%': { width: '0%' }, '100%': { width: '80%' } },
},
animation: {
  leftToRight: 'leftToRight 0.5s',
  slide: 'slide 3s',
},

That slide animation is what draws the green underline beneath the active technology tab — a CSS-only effect that would otherwise need a JS-driven transition library. Keeping animation logic in the Tailwind config rather than in component useEffect hooks keeps the components declarative: a component just toggles a class based on activeTech === item.id, and the animation is CSS's problem.

Lead capture without a backend framework

The contact form (used on both the dedicated Contact page and inline on the homepage) needed to reach real people, not just log to a console. Rather than pull in a form-service SDK, it's a plain FormData-driven submit handler that posts to a Next.js API route:

const handleSubmit = async (e) => {
  e.preventDefault();
  setLoading(true);
  const formData = new FormData(e.target);
  const data = {};
  contactSection.inputs.forEach(({ name }) => (data[name] = formData.get(name)));
  await sendMessage(data);
  router.push("/thank-you");
};
// api/message.js
export const sendMessage = async (message) => {
  const response = await axios.post('/message', message);
  return response.data;
};

The form config itself — field names, placeholders, validation flags — is data, not markup, so the same inputs.map(...) renders every field including a custom phone-number validity message ("Phone number at least 10 digits"). Adding a field to the contact form is a one-line array edit, not a new block of JSX.

Trade-offs made on purpose

DecisionAlternative consideredWhy we chose this
One page + component tree per case studyCMS-driven [slug].js templateCase studies are closed engagements with bespoke layouts; static pre-rendering with zero runtime fetches beats content-editing convenience nobody needed
Tailwind utilities + config-level keyframesComponent library (MUI) for all UIFull control over the agency's specific visual language (the #1caf83 green, the font-teko display type) without fighting a library's defaults
Data-driven tab/form configsHardcoded JSX per tab/fieldContent changes (new tech category, new form field) become data edits, reviewable in a diff without touching render logic

Takeaway

ApexDv is proof that "content-heavy marketing site" doesn't automatically mean "reach for a CMS." When the content is a known, finite set of case studies rather than an ever-growing stream, a well-organized component tree — one folder per case study, shared primitives where they genuinely overlap, bespoke sections where they don't — gives more design flexibility and a simpler deploy story (static Next.js output, no backend to provision) than a headless CMS integration would have. The parts of the UI that are dynamic (the technology tabs, the contact form) stay dynamic through plain React state and data arrays, not through pulling in state-management or form libraries the app didn't need.