{"id":2963,"date":"2026-05-11T22:58:58","date_gmt":"2026-05-11T15:58:58","guid":{"rendered":"https:\/\/daiilynews.cu.ma\/i-built-a-startup-waitlist-landing-page-in-next-js-15-here-are-all-the-decisions-i-made\/"},"modified":"2026-05-11T22:58:58","modified_gmt":"2026-05-11T15:58:58","slug":"i-built-a-startup-waitlist-landing-page-in-next-js-15-here-are-all-the-decisions-i-made","status":"publish","type":"post","link":"https:\/\/daiilynews.cu.ma\/?p=2963","title":{"rendered":"I built a startup waitlist landing page in Next.js 15 \u2014 here are all the decisions I made"},"content":{"rendered":"<p> <br \/>\n<br \/>\n                I&#8217;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.<\/p>\n<p>Here&#8217;s a breakdown of every technical decision I made.<\/p>\n<p>  Why Next.js 15 with CSS Modules (no Tailwind)<\/p>\n<p>Most templates use Tailwind. That&#8217;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.<\/p>\n<p>CSS Modules give you:<\/p>\n<p>Locally scoped class names (no conflicts)<br \/>\nStandard CSS syntax (no utility memorization)<br \/>\nZero runtime cost<br \/>\nWorks with Next.js out of the box<\/p>\n<p>The tradeoff is more verbose than Tailwind for repetitive utilities. Worth it for a product you&#8217;re selling.<\/p>\n<p>  The bento grid \u2014 1px gap trick<\/p>\n<p>The features section uses CSS Grid with grid-template-columns: repeat(3, 1fr). The first card spans 2 columns via grid-column: span 2.<\/p>\n<p>.bento {<br \/>\n  display: grid;<br \/>\n  grid-template-columns: repeat(3, 1fr);<br \/>\n  gap: 1px;<br \/>\n  background: var(&#8211;color-border-subtle); \/* gap IS the border *\/<br \/>\n  border-radius: var(&#8211;radius-lg);<br \/>\n  overflow: hidden;<br \/>\n}<\/p>\n<p>.bento .card:first-child {<br \/>\n  grid-column: span 2;<br \/>\n}<\/p>\n<p>    Enter fullscreen mode<\/p>\n<p>    Exit fullscreen mode<\/p>\n<p>Instead of adding borders to each card, I set the grid&#8217;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.<\/p>\n<p>  Count-up animation with IntersectionObserver<\/p>\n<p>The metrics section triggers a count-up when the section enters the viewport:<\/p>\n<p>const observer = new IntersectionObserver(((entry)) => {<br \/>\n  if (entry.isIntersecting &#038;&#038; !started.current) {<br \/>\n    started.current = true<br \/>\n    const startTime = performance.now()<\/p>\n<p>    const tick = (now: number) => {<br \/>\n      const progress = Math.min((now &#8211; startTime) \/ duration, 1)<br \/>\n      const eased = 1 &#8211; Math.pow(1 &#8211; progress, 3) \/\/ cubic ease-out<br \/>\n      setCount(Math.round(eased * end))<br \/>\n      if (progress  1) requestAnimationFrame(tick)<br \/>\n    }<\/p>\n<p>    requestAnimationFrame(tick)<br \/>\n  }<br \/>\n}, { threshold: 0.4 })<\/p>\n<p>    Enter fullscreen mode<\/p>\n<p>    Exit fullscreen mode<\/p>\n<p>The started ref prevents re-triggering if the user scrolls away and back. Cubic ease-out feels much more natural than linear. No library \u2014 30 lines of TypeScript.<\/p>\n<p>  Infinite logo marquee (CSS-only)<\/p>\n<p>.row {<br \/>\n  display: flex;<br \/>\n  gap: 64px;<br \/>\n  width: max-content;<br \/>\n  animation: marquee 24s linear infinite;<br \/>\n}<\/p>\n<p>@keyframes marquee {<br \/>\n  from { transform: translateX(0); }<br \/>\n  to   { transform: translateX(-50%); }<br \/>\n}<\/p>\n<p>    Enter fullscreen mode<\/p>\n<p>    Exit fullscreen mode<\/p>\n<p>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:<\/p>\n<p>.track {<br \/>\n  mask-image: linear-gradient(<br \/>\n    to right,<br \/>\n    transparent 0%, black 12%, black 88%, transparent 100%<br \/>\n  );<br \/>\n}<\/p>\n<p>    Enter fullscreen mode<\/p>\n<p>    Exit fullscreen mode<\/p>\n<p>  Single content file<\/p>\n<p>All editable content \u2014 name, copy, nav links, logos, features, metrics, testimonials, FAQ \u2014 lives in src\/lib\/constants.ts. The buyer touches one file and the whole page updates. No hunting through components.<\/p>\n<p>  Design tokens in globals.css<\/p>\n<p>8 variables to rebrand the entire template:<\/p>\n<p>:root {<br \/>\n  &#8211;color-accent: #f59e0b;   \/* change this \u2192 full rebrand *\/<br \/>\n  &#8211;color-bg:     #09090b;<br \/>\n  &#8211;font-display: &#8216;Sora&#8217;, sans-serif;<br \/>\n  &#8211;font-body:    &#8216;IBM Plex Sans&#8217;, sans-serif;<br \/>\n  &#8211;radius-lg:    16px;<br \/>\n}<\/p>\n<p>    Enter fullscreen mode<\/p>\n<p>    Exit fullscreen mode<\/p>\n<p>  Connecting the waitlist form<\/p>\n<p>The form ships with a simulated delay. Replace it with your stack:<\/p>\n<p>\/* ConvertKit *\/<br \/>\nawait fetch(`https:\/\/api.convertkit.com\/v3\/forms\/${FORM_ID}\/subscribe`, {<br \/>\n  method: &#8216;POST&#8217;,<br \/>\n  headers: { &#8216;Content-Type&#8217;: &#8216;application\/json&#8217; },<br \/>\n  body: JSON.stringify({ api_key: KEY, email }),<br \/>\n})<\/p>\n<p>\/* Loops *\/<br \/>\nawait fetch(&#8216;https:\/\/app.loops.so\/api\/v1\/contacts\/create&#8217;, {<br \/>\n  method: &#8216;POST&#8217;,<br \/>\n  headers: { Authorization: `Bearer ${KEY}`, &#8216;Content-Type&#8217;: &#8216;application\/json&#8217; },<br \/>\n  body: JSON.stringify({ email }),<br \/>\n})<\/p>\n<p>    Enter fullscreen mode<\/p>\n<p>    Exit fullscreen mode<\/p>\n<p>Live demo: https:\/\/orbit-landing-iota.vercel.app\/<\/p>\n<p>The template is available on Gumroad for $29: https:\/\/devmaya.gumroad.com\/l\/orbit<\/p>\n<p><br \/>\n<br \/><a href=\"https:\/\/dev.to\/juan_maya_6479056cdf0c8d6\/i-built-a-startup-waitlist-landing-page-in-nextjs-15-here-are-all-the-decisions-i-made-2597\">Source link <\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>I&#8217;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&#8217;s a breakdown of every technical decision I made. Why Next.js 15 with CSS Modules (no Tailwind) Most templates use Tailwind. That&#8217;s fine for customization, but [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":2964,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[676],"tags":[761,765,1222,762,763,764,1220,1221,760,824],"class_list":["post-2963","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tech-ai","tag-coding","tag-community","tag-css","tag-development","tag-engineering","tag-inclusive","tag-nextjs","tag-react","tag-software","tag-webdev"],"_links":{"self":[{"href":"https:\/\/daiilynews.cu.ma\/index.php?rest_route=\/wp\/v2\/posts\/2963","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/daiilynews.cu.ma\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/daiilynews.cu.ma\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/daiilynews.cu.ma\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/daiilynews.cu.ma\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=2963"}],"version-history":[{"count":0,"href":"https:\/\/daiilynews.cu.ma\/index.php?rest_route=\/wp\/v2\/posts\/2963\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/daiilynews.cu.ma\/index.php?rest_route=\/wp\/v2\/media\/2964"}],"wp:attachment":[{"href":"https:\/\/daiilynews.cu.ma\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=2963"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/daiilynews.cu.ma\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=2963"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/daiilynews.cu.ma\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=2963"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}