Building a Landing Page with Next.js 16, Tailwind v4, and Framer Motion
Piotr Żarów
CTO at Dev and Deliver
2026-07-30
#Development
Time to read
11 mins
In this article
Introduction
The short version
Why These Design Decisions Aren't Aesthetic
Page structure
Why two typefaces instead of monospace everywhere
Navbar: floating pill, no library
Hero: two-column layout with an animated terminal
Features: the double-bezel card pattern
How to build a marquee with no JavaScript
Scroll reveals with IntersectionObserver, Not a library
WaitlistForm: Framer Motion, Zod, and a 10-second timeout
Shared UI: packages/ui
NestJS waitlist endpoint
Where to put security headers in Next.js
Real pain points
FAQ
Agency take
Share this article
Introduction
Article 1 covered the monorepo scaffold: Turborepo, Yarn Berry, shared types between NestJS and Next.js. This one is about what we built on top of it - the actual landing page for the open-source starter, plus the waitlist flow that connects it to a real NestJS backend.
The page itself is live. Eight sections, one accent color, no icon library, no database. Here's how it went.
The short version
- Tailwind CSS v4 - CSS-first config, no
tailwind.config.ts,@keyframesdefined inglobals.css. Moving to v4 from v3 is a bigger mental model shift than the diff suggests. - JetBrains Mono + IBM Plex Sans via
next/font. One is the identity; the other is the legible body text most people forget to include on "monospace brand" sites. - Dark mode only. Doubles QA surface for no gain. We made a call and didn't look back.
- Framer Motion scoped tight -
TerminalWindowandWaitlistFormonly. Everything else is CSS transitions or a customIntersectionObserverscroll reveal. - CSS-only marquee. No JS, no scroll event, no IntersectionObserver. Two rows, two
@keyframes, one CSSmaskImagefor fading edges. - WaitlistForm validates with
CreateWaitlistEntrySchemaon the client before the network call. Same Zod schema the NestJS DTOimplements. One schema, both ends. - Resend Audiences as the subscriber store. No database.
contacts.create()returns an error on duplicate - we use that to skip the welcome email for re-subscribers. - Five things broke. All documented below.
Why These Design Decisions Aren't Aesthetic
Before getting into components, the design decisions that show up throughout the code:
| Decision | Why it matters in code |
|---|---|
| Dark mode only (D8) | One theme to QA, one set of CSS custom properties |
| JetBrains Mono throughout (D7) | Loaded via 'next/font', exposed as '--font-mono' CSS var |
| Emerald-500 as sole accent (D12) | Single color token -no palette management |
| No icon library (D11) | Inline SVG only -zero bundle cost, full control over sizing |
| 44×44px minimum touch targets (D16) | Enforced in the component, not remembered per-usage |
| 'transition-[property]' not 'transition-all' (D17) | Stops layout-affecting properties from being animated |
Dark mode only is the one people question. The honest case: a dev agency site gets no consumer traffic. Every visitor is a developer or a potential client who has already clicked through from a technical context. They almost certainly use a dark-mode browser. Implementing and QA-ing two themes for that audience is effort that belongs on the product we're building for them, not on the site we use to describe ourselves.
Page structure
The page is eight sections in order:
Navbar → Hero → Features → TechStack → Articles → WaitlistForm → Contact → Footer
apps/web/src/app/page.tsx is a Server Component that fetches from Contentful (covered in article 3) and hands content down as props. The page itself doesn't know or care where the content came from:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20export default async function Home() { const [hero, features, articles] = await Promise.all([ fetchHero(), fetchFeatures(), fetchArticles(), ]); return ( <main className="overflow-x-hidden"> <Navbar /> <Hero headline={hero.headline} subline={hero.subline} /> <Features items={features} /> <TechStack /> <Articles articles={articles} /> <WaitlistForm /> <Contact /> <Footer /> </main> ); }
This structure is a deliberate decision (D10). Hero and Features accept content as props with hardcoded defaults. The Contentful integration in article 3 is a one-line change at the call site - the components don't move.
Why two typefaces instead of monospace everywhere
The obvious move for a "terminal green monospace identity" site is to use a monospace font everywhere. We didn't do that.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15// apps/web/src/app/layout.tsx import { JetBrains_Mono, IBM_Plex_Sans } from "next/font/google"; const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono", display: "swap", }); const ibmPlexSans = IBM_Plex_Sans({ subsets: ["latin"], weight: ["300", "400", "500"], variable: "--font-sans", display: "swap", });
JetBrains Mono is the brand font - headlines, labels, CTAs, code. IBM Plex Sans is the body font - sublines, descriptions, longer copy. Tailwind sees them as font-mono and font-sans via CSS variables set on the <html> element. Mixing a geometric sans into a monospace-identity site is what stops it from reading like a terminal emulator someone forgot to style.
next/font self-hosts both. No external font requests at runtime, which matters for the Content-Security-Policy - font-src 'self' works without exceptions.
Navbar: floating pill, no library
The navbar is a floating pill with a frosted glass effect. On desktop it's a static element. On mobile it has a full-screen overlay with staggered link animations.
1<nav className="flex items-center gap-6 px-5 py-2.5 rounded-full bg-white/5 backdrop-blur-xl ring-1 ring-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.4)]">
backdrop-blur-xl handles the frosted glass. ring-1 ring-white/10 is the outer edge - the same ring pattern we use on cards throughout. The mobile overlay uses CSS transitions with transitionDelay set inline per link to create the stagger:
1 2 3 4 5 6 7<a href={link.href} className={`transition-[transform,opacity] duration-400 ${ open ? "translate-y-0 opacity-100" : "translate-y-8 opacity-0" }`} style={{ transitionDelay: open ? `${i * 60}ms` : "0ms" }} >
When closing, transitionDelay is set to 0ms for all links so they collapse immediately rather than staggering out in reverse. That's the detail that makes the animation feel native rather than scripted.
No Framer Motion here - pure CSS transitions. The only client-side logic is the open boolean state and the onClick handlers.
Hero: two-column layout with an animated terminal
The hero is a two-column grid on desktop - copy left, a fake terminal window right - that stacks to a single column on mobile.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25export function Hero({ headline = "We build things\nthat work.", subline = "Full-stack products built on TypeScript, NestJS, and Next.js. Open source tooling, production-grade architecture.", }: HeroProps) { return ( <section className="relative min-h-[100dvh] flex flex-col justify-center px-6 md:px-12 lg:px-20 pt-28 pb-24"> <div className="relative z-10 max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center w-full"> <div> <h1 className="font-mono font-bold tracking-tighter leading-none text-zinc-50 mb-6" style={{ fontSize: "clamp(2.5rem, 5vw, 5.5rem)", whiteSpace: "pre-line" }} > {headline} </h1> <a href="#contact" className="inline-flex items-center px-6 py-2.5 rounded-full bg-emerald-500 hover:bg-emerald-400 text-zinc-950 font-mono text-sm font-medium transition-[background-color,transform]"> Contact us </a> </div> <div className="flex justify-center lg:justify-end"> <TerminalWindow /> </div> </div> </section> ); }
min-h-[100dvh] not min-h-screen - more on that in the pain points section.
The headline uses clamp() for fluid typography instead of responsive breakpoints. whiteSpace: "pre-line" lets the content string use \n for line breaks - so when the CMS delivers the headline, it can control the break without HTML entities.
TerminalWindow
TerminalWindow is a "use client" component that uses Framer Motion to animate four package rows sequentially:
1 2 3 4 5 6const packages = [ { label: "types:dev", status: "compiled", time: "0.1s", port: null }, { label: "ui:dev", status: "compiled", time: "0.2s", port: null }, { label: "api:dev", status: "ready", time: "1.2s", port: "3001" }, { label: "web:dev", status: "ready", time: "0.7s", port: "3000" }, ];
Each row animates in with staggerChildren: 0.15. The final "4 successful, ready in 1.4s" line has a blinking cursor via a CSS keyframe animation. The terminal is purely decorative - it communicates the monorepo architecture at a glance without a paragraph of explanation.
This is the first Framer Motion boundary in the page. Because TerminalWindow is "use client", the Hero component itself stays a Server Component (it imports TerminalWindow but doesn't use any client hooks itself).
Features: the double-bezel card pattern
Every card in the Features section uses the same two-layer structure (D13):
1 2 3 4 5<div className="ring-1 ring-white/5 hover:ring-white/[0.12] p-1.5 rounded-[2rem] bg-white/[0.02] hover:bg-white/[0.04] transition-colors duration-200"> <div className="rounded-[calc(2rem-0.375rem)] bg-zinc-900 shadow-[inset_0_1px_1px_rgba(255,255,255,0.06)] p-7"> {/* content */} </div> </div>
Outer: a subtle ring and near-transparent background. Inner: a solid zinc-900 surface with an inset highlight shadow. The inner border-radius is calc(2rem - 0.375rem) - exactly tracking the outer padding so the corner radii nest cleanly. hover:ring-white/[0.12] gives feedback on hover without a color change.
The inset_0_1px_1px shadow is the load-bearing piece. It simulates a top highlight that separates the card from the background in the same way glass catches light - without a visible border.
How to build a marquee with no JavaScript
Two rows of tech names scroll in opposite directions. Zero JS. Zero IntersectionObserver. Zero Framer Motion. Just CSS:
1 2 3 4 5 6 7 8 9 10 11 12 13 14function MarqueeRow({ reverse = false }: { reverse?: boolean }) { const doubled = [...items, ...items]; return ( <div className="flex overflow-hidden"> <div className={`flex shrink-0 gap-10 ${reverse ? "animate-marquee-reverse" : "animate-marquee"}`}> {doubled.map((item, i) => ( <span key={i} className="font-mono text-sm text-zinc-500 whitespace-nowrap select-none"> {item}<span className="ml-10 text-zinc-700">·</span> </span> ))} </div> </div> ); }
The @keyframes live in globals.css:
1 2 3 4 5 6 7 8 9 10 11 12@keyframes marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } } .animate-marquee { animation: marquee 30s linear infinite; } .animate-marquee-reverse { animation: marquee 30s linear infinite reverse; }
translateX(-50%) works because the inner div contains the items list duplicated - [...items, ...items]. When it slides left by exactly half its width, it looks identical to its starting position and loops seamlessly. shrink-0 on the inner div is what makes the math hold: without it, flexbox compresses the children and the 50% calculation is wrong.
The fading edges at the left and right are maskImage on the section element:
1 2 3 4 5 6<section style={{ maskImage: "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)", WebkitMaskImage: "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)", }} >
No wrapper elements. No pseudo-elements. One inline style, both vendor prefixes. The content fades at 12% from each edge.
Tailwind v4 changed how you add custom animations. In v3 you'd add them to tailwind.config.ts under theme.extend.animation. In v4 there is no tailwind.config.ts - you write the @keyframes in CSS and add the class name directly. Cleaner, but the muscle memory of reaching for the config file gets you every time.
Scroll reveals with IntersectionObserver, Not a library
Elements with data-animate start at opacity: 0 and slide up. When they enter the viewport, data-visible is added and they transition in:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15[data-animate] { opacity: 0; transform: translateY(1rem) translateZ(0); filter: blur(2px); transition-property: opacity, transform, filter; transition-duration: 0.5s; transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1); transition-delay: var(--animate-delay, 0ms); } [data-animate][data-visible] { opacity: 1; transform: translateY(0) translateZ(0); filter: blur(0); }
The ScrollAnimations component sets up the observer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23"use client"; export function ScrollAnimations() { useEffect(() => { const raf = requestAnimationFrame(() => { const elements = document.querySelectorAll<HTMLElement>("[data-animate]"); const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.setAttribute("data-visible", ""); observer.unobserve(entry.target); } }); }, { threshold: 0.1, rootMargin: "0px 0px -40px 0px" }, ); elements.forEach((el) => observer.observe(el)); }); return () => cancelAnimationFrame(raf); }, []); return null; }
The requestAnimationFrame wrapper is not optional. Without it, the observer fires before the browser has painted the initial opacity: 0 state. The element is already visible when the observer marks it visible - no transition plays, elements just snap in. Wrapping in rAF defers observer setup until after the first paint, giving CSS time to apply the starting state before the observer can override it.
prefers-reduced-motion is respected in CSS - animated elements are fully visible with no transition for users who have requested reduced motion.
Staggered delays on adjacent elements use a CSS custom property:
1style={{ "--animate-delay": `${i * 80}ms` } as React.CSSProperties}
The as React.CSSProperties cast is required because TypeScript doesn't know about custom properties. The value flows into the transition-delay: var(--animate-delay, 0ms) in CSS.
WaitlistForm: Framer Motion, Zod, and a 10-second timeout
The form is the second and last "use client" Framer Motion boundary:
1 2 3 4"use client"; import { AnimatePresence, motion } from "framer-motion"; import { CreateWaitlistEntrySchema } from "@repo/types"; import { Input } from "@repo/ui";
Three things happen in sequence on submit:
1. Zod validates before the network call.
1 2 3 4 5 6 7 8 9 10const result = CreateWaitlistEntrySchema.safeParse(raw); if (!result.success) { const errors: Record<string, string> = {}; result.error.issues.forEach((issue) => { const field = String(issue.path[0]); errors[field] = issue.message; }); setFieldErrors(errors); return; }
Format errors never make a round-trip. An invalid email address is caught here, field errors are set from issue.path, and the function returns. This is the same CreateWaitlistEntrySchema that the NestJS DTO implements - the Zod schema is the contract for both sides.
2. AbortController with a 10-second timeout.
1 2 3 4 5 6 7 8 9const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10_000); const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/waitlist`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(result.data), signal: controller.signal, }); clearTimeout(timeoutId);
Without this, a slow or unreachable API endpoint leaves the form in a permanent loading state. AbortController gives fetch a deadline. The clearTimeout after the response prevents the abort from firing on a slow-but-successful request.
3. AnimatePresence swaps the form for a success state.
1 2 3 4 5 6 7 8 9 10 11<AnimatePresence mode="wait"> {state === "success" ? ( <motion.div key="success" {...fadeSlide}> {/* success card */} </motion.div> ) : ( <motion.form key="form" onSubmit={handleSubmit} {...fadeSlide}> {/* form fields */} </motion.form> )} </AnimatePresence>
mode="wait" ensures the form exits before the success card enters. Both share the same fadeSlide variant - fade out up, fade in up. The key prop is what tells AnimatePresence these are different elements; without it, Framer Motion sees a single element changing content and skips the exit animation.
Shared UI: packages/ui
Button and Input live in packages/ui and are built on top of native HTML elements. Input is representative:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24export function Input({ label, labelClassName, error, className, id, ...props }: InputProps) { const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); return ( <div className="flex flex-col gap-1"> {label && ( <label htmlFor={inputId} className={`text-sm font-medium ${labelClassName ?? "text-zinc-200"}`}> {label} </label> )} <input id={inputId} className={[ "h-10 rounded-md border border-zinc-700 px-3 text-sm transition-[border-color,box-shadow]", "focus:outline-none focus:ring-1", error ? "border-red-500" : undefined, className, ].filter(Boolean).join(" ")} {...props} /> {error && <p className="text-xs text-red-400">{error}</p>} </div> ); }
InputProps extends React.InputHTMLAttributes<HTMLInputElement>. The ...props spread means Input accepts everything a native <input> does - name, type, required, autoComplete, disabled — without explicitly listing them. The component adds the accessibility linkage between label and input (htmlFor/id), the error state, and the base styling.
The 44×44px touch target minimum (D16) is enforced by the h-10 base class on the input (40px) plus the h-11 override in WaitlistForm. The submit button explicitly uses h-11 (44px).
These components are available to any future apps/admin or apps/dashboard app in the monorepo. NestJS doesn't use them, but they're not coupled to apps/web - they live in packages/.
NestJS waitlist endpoint
POST /waitlist validates with NestJS's ValidationPipe (wired globally in main.ts), then hands off to WaitlistService:
1 2 3 4 5async subscribe(dto: CreateWaitlistDto): Promise<{ email: string }> { const added = await this.mail.addToAudience(dto.email); if (added) this.mail.sendWelcomeEmail(dto.email); return { email: dto.email }; }
The DTO:
1 2 3 4 5export class CreateWaitlistDto implements CreateWaitlistEntry { @ApiProperty({ example: "jane@example.com" }) @IsEmail() email!: string; }
implements CreateWaitlistEntry is the type-system contract from article 1. The @IsEmail() class-validator decorator enforces it at runtime through ValidationPipe. The @ApiProperty decorator populates Swagger at /docs.
Why Resend Audiences Instead of a Database
No database. addToAudience calls resend.contacts.create():
1 2 3 4 5 6 7 8 9 10 11 12async addToAudience(email: string): Promise<boolean> { if (!this.resend || !this.audienceId) return false; const { error } = await this.resend.contacts.create({ audienceId: this.audienceId, email, }); if (error) { this.logger.error(`Failed to add ${email} to audience: ${error.message}`); return false; } return true; }
The return value tells WaitlistService whether to fire the welcome email. If error is set, it returns false. We use that boolean as the "newly added" signal - if adding the contact failed (including because they already exist and Resend returned an error), the welcome email doesn't fire. Decision D22.
sendWelcomeEmail is fire-and-forget:
1 2 3 4 5 6sendWelcomeEmail(email: string): void { if (!this.resend) return; this.resend.emails.send({ ... }) .then(() => this.logger.log(`Welcome email sent to ${email}`)) .catch((err: Error) => this.logger.error(`Failed to send welcome email: ${err.message}`)); }
It returns void, not a Promise. The HTTP response doesn't wait for the email to send. If the email fails, the subscriber is still in Resend Audiences - we log the error and move on. The user already got a 200.
Where to put security headers in Next.js
Headers are set in next.config.ts - not in middleware, not in a separate config layer. One file, all headers:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25const apiOrigin = (() => { try { return new URL(process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001").origin; } catch { return "http://localhost:3001"; } })(); const securityHeaders = [ { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, { key: "Content-Security-Policy", value: [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval'", "style-src 'self' 'unsafe-inline'", "font-src 'self'", "img-src 'self' data:", `connect-src 'self' ${apiOrigin}`, ].join("; "), }, ];
connect-src is built from NEXT_PUBLIC_API_URL at build time. On local dev it resolves to http://localhost:3001. In production it resolves to the deployed API URL. Localhost never reaches the production CSP. (D19)
The IIFE with a try/catch handles the case where NEXT_PUBLIC_API_URL is missing or not a valid URL - it falls back to localhost rather than crashing the build.
Real pain points
1. Framer Motion forces a "use client" boundary on its parent
The instinct is to put TerminalWindow inside Hero and let both be Server Components. That breaks the moment you add motion.div to TerminalWindow - Framer Motion uses React context internally, which requires client-side rendering. The boundary has to exist somewhere.
Our approach: every component that uses Framer Motion is explicitly "use client" and is as small as possible. Hero itself stays a Server Component; it just imports a client component. WaitlistForm is the same - the outer page component doesn't become client-side just because its child does.
The rule we follow: if a component imports from framer-motion, it gets "use client". If it only imports other components that happen to be client components, it doesn't need to be.
2. 'transition-all' animates layout-affecting properties
This one is easy to miss because transition-all usually looks fine in local testing. The problem shows up on lower-end hardware or during layout-heavy moments: properties like display, width, and grid-template-columns get included in the transition. The browser animates them whether or not they changed, burning GPU/CPU cycles on every style recalculation.
transition-[background-color,transform] is explicit. It tells the browser exactly what to watch. Both of those properties are composited - they run on the GPU and don't trigger layout. This matters most on buttons and interactive elements that users tap on mobile.
1className="transition-[background-color,transform] duration-200"
We enforce this across every component. transition-colors from Tailwind is acceptable for color-only transitions. transition-all is not.
3. The CSS marquee gap
The seamless loop depends on the inner div being exactly twice the width of a single list of items. If shrink-0 isn't set on the inner flex container, flexbox is free to compress the children. The translated -50% then overshoots the loop point, causing a gap before the items repeat.
The fix is two properties working together:
1 2 3 4 5<div className="flex overflow-hidden"> {/* clips the scroll */} <div className="flex shrink-0 gap-10 animate-marquee"> {/* shrink-0 is load-bearing */} {doubled.map(...)} </div> </div>
overflow-hidden on the outer div clips. shrink-0 on the inner div prevents compression. Without the combination, either the items overflow visibly or the loop has a visible seam.
4. 'min-h-[100dvh]' vs 'min-h-screen'
100vh on iOS includes the browser chrome (address bar + home indicator). When the browser chrome hides as the user scrolls down, 100vh is suddenly larger than the visible viewport, causing a jarring layout shift as the hero section reflows.
100dvh (dynamic viewport height) tracks the currently visible viewport. It shrinks when the browser chrome hides. The hero section fills the screen at all times without reflow. This is a CSS spec addition from 2022, supported in all modern browsers.
min-h-screen in Tailwind maps to min-height: 100vh. Use min-h-[100dvh] for full-viewport sections on anything that runs on a mobile browser.
5. Resend Audiences doesn't throw on duplicates
When you call resend.contacts.create() with an email address that's already in the audience, Resend doesn't throw an exception. It returns an error object in the destructured response. Without checking for it, you'd assume contacts.create() succeeded and fire the welcome email on every re-subscribe.
Our addToAudience function returns false on any error - including the duplicate case:
1 2 3const { error } = await this.resend.contacts.create({ audienceId, email }); if (error) return false; return true;
WaitlistService only sends the welcome email when addToAudience returns true. A duplicate submission gets a 200 response, the form shows the success state, and no second welcome email is sent. (D22)
FAQ
Do I need tailwind.config.ts in Tailwind v4?
No. v4 is CSS-first: configuration and @keyframes live in globals.css. Moving from v3 is a bigger mental shift than a version bump suggests.
How many "use client" boundaries does this page need?
Two - the TerminalWindow in the hero and the WaitlistForm. Framer Motion forces a client boundary onto its parent, so keeping it to two components keeps the rest of the page server-rendered.
Can I build a scrolling marquee without JavaScript?
Yes. Two rows scrolling in opposite directions, done entirely in CSS - no IntersectionObserver, no Framer Motion, no JS at all.
Why not use 'transition-all'?
It animates layout-affecting properties too, which is easy to miss because it usually looks fine locally. The cost appears on lower-end hardware and during layout-heavy moments. Name the properties you actually want to transition.
Where do CSP and security headers belong in Next.js?
In next.config.ts - one file, all headers. Not middleware, not a separate config layer.
Do I need a database to store waitlist signups?
Not for this. Resend Audiences acts as the subscriber store via resend.contacts.create(). Note that it doesn't throw on duplicates, so handle that case explicitly.
Agency take
The landing page we ended up with makes some choices that look opinionated but were made for concrete reasons. Dark mode only saves QA time on a site that doesn't need two themes. CSS-only animations keep the client bundle smaller and the Server Component surface larger. Framer Motion is used exactly where the animation requires JavaScript - the animated terminal and the form success state swap - and nowhere else.
The thing that paid off most is the props-first architecture for content components. Hero and Features accept their content as props with sensible defaults. The page passes content from Contentful. The components don't know the difference. When we wired up Contentful in article 3, the component files didn't change at all.
The pattern we'd push back on if we could redo it: managing Tailwind v4's animation syntax wasn't hard once we understood it, but the mental model shift from "config file" to "CSS file" cost a couple of hours. The documentation assumes you're starting fresh rather than migrating. Budget time for that.
The open-source starter is at https://github.com/DevAndDeliver/turborepo. Clone it, run pnpm dev, and the landing page is live at localhost:3000. Article 3 covers how we wired the hero copy and feature cards to Contentful without touching a single component.
Dev and Deliver is a software agency based in Kraków, Poland. We build production web and mobile products for startups and scale-ups. devanddeliver.com
Piotr Żarów
CTO at Dev and Deliver
Share this post
Related posts
Want to light up your ideas with us?
Kickstart your new project with us in just 1 step!
Prefer to call or write a traditional e-mail?









