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, @keyframes defined in globals.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 - TerminalWindow and WaitlistForm only. Everything else is CSS transitions or a custom IntersectionObserver scroll reveal.
  • CSS-only marquee. No JS, no scroll event, no IntersectionObserver. Two rows, two @keyframes, one CSS maskImage for fading edges.
  • WaitlistForm validates with CreateWaitlistEntrySchema on the client before the network call. Same Zod schema the NestJS DTO implements. 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:

DecisionWhy 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
20
export 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.

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
25
export 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
6
const 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
14
function 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:

1
style={{ "--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
10
const 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
9
const 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
24
export 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
5
async 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
5
export 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
12
async 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
6
sendWelcomeEmail(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
25
const 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.

1
className="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
3
const { 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?