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. I 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. I 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 I made on purpose
| Decision | Alternative considered | Why I chose this |
|---|---|---|
| One page + component tree per case study | CMS-driven [slug].js template | Case studies are closed engagements with bespoke layouts; static pre-rendering with zero runtime fetches beats content-editing convenience nobody needed |
| Tailwind utilities + config-level keyframes | Component library (MUI) for all UI | Full 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 configs | Hardcoded JSX per tab/field | Content 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.