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



