DEV Community

Dev Maya
Dev Maya

Posted on

I built a startup waitlist landing page in Next.js 15 — here are all the decisions I made

I've been building Next.js templates as a side project and selling them on Gumroad. This weekend I shipped the fourth one: Orbit, a startup launch and waitlist landing page.

Here's a breakdown of every technical decision I made.

Why Next.js 15 with CSS Modules (no Tailwind)

Most templates use Tailwind. That's fine for customization, but it adds a compilation step and a learning curve for buyers who just want clean CSS they can read and edit.

CSS Modules give you:

  • Locally scoped class names (no conflicts)
  • Standard CSS syntax (no utility memorization)
  • Zero runtime cost
  • Works with Next.js out of the box

The tradeoff is more verbose than Tailwind for repetitive utilities. Worth it for a product you're selling.

The bento grid — 1px gap trick

The features section uses CSS Grid with grid-template-columns: repeat(3, 1fr). The first card spans 2 columns via grid-column: span 2.

.bento {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1px;
  background: var(--color-border-subtle); /* gap IS the border */
  border-radius: var(--radius-lg);
  overflow: hidden;
}

.bento .card:first-child {
  grid-column: span 2;
}
Enter fullscreen mode Exit fullscreen mode

Instead of adding borders to each card, I set the grid's background to the border color and use 1px gaps. The cards themselves have no borders. This gives perfectly consistent grid lines with zero extra markup.

Count-up animation with IntersectionObserver

The metrics section triggers a count-up when the section enters the viewport:

const observer = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting && !started.current) {
    started.current = true
    const startTime = performance.now()

    const tick = (now: number) => {
      const progress = Math.min((now - startTime) / duration, 1)
      const eased = 1 - Math.pow(1 - progress, 3) // cubic ease-out
      setCount(Math.round(eased * end))
      if (progress < 1) requestAnimationFrame(tick)
    }

    requestAnimationFrame(tick)
  }
}, { threshold: 0.4 })
Enter fullscreen mode Exit fullscreen mode

The started ref prevents re-triggering if the user scrolls away and back. Cubic ease-out feels much more natural than linear. No library — 30 lines of TypeScript.

Infinite logo marquee (CSS-only)

.row {
  display: flex;
  gap: 64px;
  width: max-content;
  animation: marquee 24s linear infinite;
}

@keyframes marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}
Enter fullscreen mode Exit fullscreen mode

The key: duplicate the logos array in the component and animate exactly -50% (half the total width). Seamless loop. Edge fade via mask-image on the parent:

.track {
  mask-image: linear-gradient(
    to right,
    transparent 0%, black 12%, black 88%, transparent 100%
  );
}
Enter fullscreen mode Exit fullscreen mode

Single content file

All editable content — name, copy, nav links, logos, features, metrics, testimonials, FAQ — lives in src/lib/constants.ts. The buyer touches one file and the whole page updates. No hunting through components.

Design tokens in globals.css

8 variables to rebrand the entire template:

:root {
  --color-accent: #f59e0b;   /* change this → full rebrand */
  --color-bg:     #09090b;
  --font-display: 'Sora', sans-serif;
  --font-body:    'IBM Plex Sans', sans-serif;
  --radius-lg:    16px;
}
Enter fullscreen mode Exit fullscreen mode

Connecting the waitlist form

The form ships with a simulated delay. Replace it with your stack:

/* ConvertKit */
await fetch(`https://api.convertkit.com/v3/forms/${FORM_ID}/subscribe`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ api_key: KEY, email }),
})

/* Loops */
await fetch('https://app.loops.so/api/v1/contacts/create', {
  method: 'POST',
  headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ email }),
})
Enter fullscreen mode Exit fullscreen mode

Live demo: https://orbit-landing-iota.vercel.app/

The template is available on Gumroad for $29: https://devmaya.gumroad.com/l/orbit

Top comments (0)