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;
}
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 })
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%); }
}
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%
);
}
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;
}
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 }),
})
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)