How to Build a SaaS Landing Page With Next.js and shadcn/ui
A step-by-step tutorial for scaffolding a Next.js project, initializing shadcn/ui, and building a complete, responsive SaaS landing page with a hero section, features grid, pricing table, and FAQ...
Every product needs a landing page before it needs anything else: a single page that explains what the product does, what it costs, and why someone should try it. The fastest way to build one badly is to reach for a page builder plugin or a bloated UI kit that ships components you cannot see or change. This tutorial teaches a different approach: scaffolding a real Next.js app and adding shadcn/ui components one at a time, as plain, editable source files that live in your own project.
Table Of Content
- What You Will Build
- Prerequisites
- Step 1: Scaffold a New Next.js Project
- Step 2: Tour the Generated Project
- Step 3: Initialize shadcn/ui
- Step 4: Add the Components You Need
- Step 5: Define Your Content as Data
- Step 6: Build the Header and Hero Section
- Step 7: Build the Features Grid
- Step 8: Build the Pricing Section
- Step 9: Build the FAQ Accordion (and Fix a Real Bug)
- Step 10: Build and Verify
- Step 11: Run It and Confirm the Accordion Actually Works
- Common Mistakes and Gotchas
- Confirm It All Works End to End
- Next Steps
- Sources
shadcn/ui is not a component library in the traditional sense. A traditional library like Bootstrap or Material UI ships as a package in node_modules; you import a Button, and its markup, styling, and behavior are hidden inside that package. shadcn/ui instead gives you a command-line tool that copies the actual source code of a button, a card, or an accordion directly into your project’s src/components/ui/ folder. You own that file from the moment it lands. If you want to change how a button looks or behaves, you edit the file, there is no package to fork, override, or fight with. Under the hood, each component wraps a headless, accessible primitive, meaning a library that supplies behavior and accessibility (keyboard navigation, focus management, ARIA attributes) but no visual styling of its own. As of the current shadcn CLI, that primitive layer defaults to Base UI, a headless component library from the creators of Radix UI, Floating UI, and Material UI, though you can choose Radix UI or React Aria instead when you initialize a project.
By the end of this tutorial you will have built and personally verified a complete, responsive landing page for a fictional CI/CD monitoring product called Orbital, with a navigation bar, a hero section, a features grid, a pricing table, and an FAQ accordion, and you will understand why the exact commands shown here matter, not just what to type.
What You Will Build
A single-page Next.js application with:
- A sticky header with a logo, navigation links, and a call-to-action button.
- A hero section with a status badge, a headline, supporting copy, and two buttons using different visual variants.
- A three-column features grid, rendered from a plain data array with a reusable
Cardcomponent, so adding a fourth feature later means adding one array entry, not copying and pasting markup. - A three-tier pricing table that highlights one plan using a
Badgeand conditional styling. - A frequently-asked-questions section built with an
Accordion, where each answer’s markup is not even present in the page until a visitor opens that question (a real, verified behavior of the underlying primitive, not an approximation).
Along the way you will hit a real, current bug: copying an older shadcn/ui accordion example from memory (or from an older tutorial) into a freshly-initialized project fails a TypeScript build with a genuine type error, because the component’s underlying API changed. You will see the exact error, understand why it happens, and fix it correctly instead of guessing.
Prerequisites
- Node.js 20.9 or newer, the minimum version Next.js 16 requires, and npm (bundled with Node.js). This tutorial was built and verified end to end with Node.js v24.18.0 and npm v11.16.0 on Ubuntu 26.04.
- Basic familiarity with React (components, props, JSX) and the command line. No prior experience with shadcn/ui, Tailwind CSS, or Base UI is assumed, every new concept is explained the first time it appears.
- A code editor and about 30 minutes.
Step 1: Scaffold a New Next.js Project
Open a terminal and run:
npx create-next-app@latest orbital-landing --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm
Each flag makes an explicit choice instead of leaving it to an interactive prompt: --typescript sets up TypeScript, --tailwind configures Tailwind CSS (a utility-first CSS framework where you style elements with small, composable class names like flex or text-lg instead of writing separate CSS files), --app selects the modern App Router (the src/app/ folder-based routing system), --src-dir puts your application code under src/ rather than the project root, and --import-alias "@/*" lets you write imports like @/components/ui/button instead of long relative paths like ../../../components/ui/button.
You will see npm install the dependencies, then a confirmation:
Success! Created orbital-landing at /path/to/orbital-landing
Move into the new project before continuing:
cd orbital-landing
Step 2: Tour the Generated Project
Before adding anything, look at what you already have. Open src/app/page.tsx: this is the component that renders at the site’s root URL (/). Open src/app/layout.tsx: this wraps every page and is where global fonts and metadata live. Open src/app/globals.css: this is where Tailwind’s base styles are imported and where shadcn/ui will soon add a set of CSS custom properties (variables) for theming.
Confirm the starter project runs before you change anything:
npm run dev
You should see output similar to:
▲ Next.js 16.2.12 (Turbopack)
- Local: http://localhost:3000
✓ Ready in 268ms
Open http://localhost:3000 in a browser and confirm you see the default Next.js starter page. Next.js 16 uses Turbopack, a Rust-based bundler, as its default dev and build engine, which is why you see it named in the startup banner even though you never asked for it explicitly. Stop the server with Ctrl+C once you have confirmed it; you will restart it later once the page is built.
Step 3: Initialize shadcn/ui
Run the init command:
npx shadcn@latest init
Note the package name is shadcn, not the older shadcn-ui, which is deprecated. This command asks two questions before doing any work. First:
? Select a component library
❯ Base UI (Recommended)
React Aria
Radix UI
This is the headless primitive library every generated component will be built on. Base UI is the current recommended default. If you already have experience with older shadcn/ui tutorials that use props like type="single" on an accordion, that behavior comes from Radix UI. You can select Radix UI here instead if you want that exact older API, this tutorial uses the recommended Base UI default, and you will see precisely how its API differs from the older Radix-based one later.
Second:
? Which preset would you like to use?
❯ Nova - Lucide / Geist
Vega
Maia
Lyra
...
A preset bundles a visual style with an icon set and a font pairing. Nova pairs the Lucide icon library with the Geist font family. Pick Nova for this tutorial. After answering both prompts, the CLI detects your framework and Tailwind version, writes a components.json configuration file, installs a small set of dependencies, and adds theme variables to globals.css.
Because these prompts make this command non-deterministic to reproduce exactly in writing, the rest of this tutorial uses the equivalent non-interactive flag, which locks in the same two answers:
npx shadcn@latest init -d
-d (or --defaults) is documented to select --template=next --preset=base-nova automatically, exactly the Base UI and Nova choices above. You will see:
✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS. Found v4.
✔ Validating import alias.
✔ Writing components.json.
✔ Checking registry.
✔ Installing dependencies.
✔ Updating fonts.
✔ Created 2 files:
- src/components/ui/button.tsx
- src/lib/utils.ts
✔ Updating src/app/globals.css
Project initialization completed.
Open the generated components.json. You will see fields including "style": "base-nova", "baseColor": "neutral", and "iconLibrary": "lucide", a direct, readable record of the choices you just made. Every future add command reads this file to know what to generate.
Step 4: Add the Components You Need
The init step already created button.tsx as part of its default setup. Add the remaining components this landing page needs in a single command:
npx shadcn@latest add card badge accordion separator
Expected output:
✔ Checking registry.
✔ Created 4 files:
- src/components/ui/card.tsx
- src/components/ui/badge.tsx
- src/components/ui/accordion.tsx
- src/components/ui/separator.tsx
Open src/components/ui/card.tsx and badge.tsx. Notice each file exports a small set of named pieces (Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter for cards; a single Badge with a variant prop for badges) built with Tailwind classes and a helper called class-variance-authority (imported as cva), which is what generates each component’s variant options. The variant prop on Badge, for example, accepts default, secondary, destructive, outline, ghost, or link, each mapped to a different set of Tailwind classes. Nothing here is a black box: every class your button or badge renders is sitting in that file, ready to edit.
Step 5: Define Your Content as Data
Open src/app/page.tsx, delete everything in it, and start with the imports and three plain data arrays. Keeping content separate from markup means the JSX below stays short, and adding a fourth feature or a fourth FAQ later is a one-line change, not a copy-paste job:
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
const FEATURES = [
{
title: "Pipeline timelines",
description:
"See every CI/CD run as a single timeline, from commit to deploy, with build and test stages broken out side by side.",
},
{
title: "Flaky test detection",
description:
"Orbital fingerprints test failures across runs and flags tests that fail intermittently before they block a release.",
},
{
title: "Deploy diffing",
description:
"Compare the dependency and config diff between any two deploys to find out what actually changed in production.",
},
];
const PLANS = [
{
name: "Starter",
price: "$0",
tagline: "For solo projects and evaluating Orbital",
features: ["1 pipeline", "7-day history", "Community support"],
highlighted: false,
},
{
name: "Team",
price: "$49",
tagline: "For teams shipping weekly",
features: [
"Unlimited pipelines",
"90-day history",
"Flaky test detection",
"Email support",
],
highlighted: true,
},
{
name: "Enterprise",
price: "Contact us",
tagline: "For regulated and multi-region deployments",
features: [
"SSO and audit logs",
"1-year history",
"Dedicated support engineer",
],
highlighted: false,
},
];
const FAQS = [
{
question: "Does Orbital replace my CI/CD runner?",
answer:
"No. Orbital reads events from your existing runner (GitHub Actions, GitLab CI, Jenkins, or Buildkite) through a webhook and does not run your builds itself.",
},
{
question: "How long does setup take?",
answer:
"Most teams connect their first pipeline in under 10 minutes by adding one webhook and one API token to their CI configuration.",
},
{
question: "Can I self-host Orbital?",
answer:
"Enterprise plans include a self-hosted deployment option that runs inside your own Kubernetes cluster.",
},
];
Step 6: Build the Header and Hero Section
Below the arrays, add the component itself, starting with the header and hero:
export default function Home() {
return (
<div className="flex min-h-screen flex-col bg-white dark:bg-black">
<header className="sticky top-0 z-10 border-b bg-white/80 backdrop-blur dark:bg-black/80">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<span className="text-lg font-semibold tracking-tight">
Orbital
</span>
<nav className="hidden gap-6 text-sm text-zinc-600 dark:text-zinc-400 sm:flex">
<a href="#features">Features</a>
<a href="#pricing">Pricing</a>
<a href="#faq">FAQ</a>
</nav>
<Button size="sm">Get started</Button>
</div>
</header>
<main className="flex-1">
<section className="mx-auto max-w-3xl px-6 py-24 text-center">
<Badge variant="secondary" className="mb-4">
Now supporting GitHub Actions and GitLab CI
</Badge>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
One timeline for every deploy, every flaky test, every rollback
</h1>
<p className="mt-4 text-lg text-zinc-600 dark:text-zinc-400">
Orbital connects to your existing CI/CD runner and turns raw build
logs into a timeline your whole team can read, so an on-call
engineer can find the failing step in seconds, not minutes.
</p>
<div className="mt-8 flex justify-center gap-4">
<Button size="lg">Start free</Button>
<Button size="lg" variant="outline">
Read the docs
</Button>
</div>
</section>
<Separator className="mx-auto max-w-5xl" />
The sm:flex class on the nav element means “apply display: flex only at the small breakpoint and above,” a deliberate mobile-first choice: the links are hidden by default (the base hidden class) and only shown once there is room for them. The hero uses two different components for two different jobs: Badge with variant="secondary" renders a small, muted status callout, and Button appears twice with two different combinations of size (lg for a larger, more prominent button) and variant (the default filled style for the primary action, outline for the secondary one).
Step 7: Build the Features Grid
Add a features section directly after the separator, mapping over the FEATURES array you defined in Step 5 instead of writing each card by hand:
<section id="features" className="mx-auto max-w-5xl px-6 py-20">
<h2 className="text-center text-3xl font-semibold tracking-tight">
Everything you need to trust your pipeline
</h2>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{FEATURES.map((feature) => (
<Card key={feature.title}>
<CardHeader>
<CardTitle>{feature.title}</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
</Card>
))}
</div>
</section>
<Separator className="mx-auto max-w-5xl" />
CardHeader groups a CardTitle and a CardDescription, and the grid classes (grid gap-6 sm:grid-cols-3) lay the three cards out in a single row once the viewport is wide enough, stacking them vertically below that. Because the array holds three objects, .map() produces exactly three cards; adding a fourth object to FEATURES is the only change needed to add a fourth card.
Step 8: Build the Pricing Section
Pricing tables need one plan to stand out. Add this section, which reads each plan’s highlighted flag from the PLANS array to conditionally apply different classes, a Badge, and a different button variant:
<section id="pricing" className="mx-auto max-w-5xl px-6 py-20">
<h2 className="text-center text-3xl font-semibold tracking-tight">
Plans that scale with your pipeline
</h2>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{PLANS.map((plan) => (
<Card
key={plan.name}
className={plan.highlighted ? "border-primary shadow-lg" : ""}
>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{plan.name}</CardTitle>
{plan.highlighted && <Badge>Most popular</Badge>}
</div>
<CardDescription>{plan.tagline}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">{plan.price}</p>
<ul className="mt-4 space-y-2 text-sm text-zinc-600 dark:text-zinc-400">
{plan.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
variant={plan.highlighted ? "default" : "outline"}
>
Choose {plan.name}
</Button>
</CardFooter>
</Card>
))}
</div>
</section>
<Separator className="mx-auto max-w-5xl" />
The Team plan is the only one with highlighted: true in the array, so it is the only card that receives border-primary shadow-lg, the only one that renders the “Most popular” Badge, and the only one whose button uses the default filled variant instead of outline. Using data to drive all three differences means there is exactly one place to change which plan is featured, the highlighted field, not three separate places to keep in sync.
Step 9: Build the FAQ Accordion (and Fix a Real Bug)
Add the FAQ section. If you are recalling an older shadcn/ui tutorial from memory, you might reach for this, which matches the classic Radix UI-based accordion API:
<section id="faq" className="mx-auto max-w-3xl px-6 py-20">
<h2 className="text-center text-3xl font-semibold tracking-tight">
Frequently asked questions
</h2>
<Accordion type="single" collapsible className="mt-8">
{FAQS.map((faq, index) => (
<AccordionItem key={faq.question} value={`item-${index}`}>
<AccordionTrigger>{faq.question}</AccordionTrigger>
<AccordionContent>{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</section>
</main>
Run the production build to check your work so far:
npm run build
This fails with a genuine TypeScript error, not a typo:
Type error: Type '{ children: Element[]; type: string; collapsible: true; className: string; }' is not assignable to type 'IntrinsicAttributes & Props<any>'.
Property 'type' does not exist on type 'IntrinsicAttributes & Props<any>'.
This is the direct, visible consequence of the choice you made back in Step 3. Because this project was initialized with Base UI (the Nova preset’s underlying library) rather than Radix UI, the generated accordion.tsx wraps @base-ui/react/accordion instead of Radix’s accordion primitive, and Base UI’s Accordion.Root component has a different API. Instead of a type string prop ("single" or "multiple") plus a separate collapsible boolean, Base UI’s root takes a single multiple boolean (default false, meaning single-open behavior with no separate flag needed for collapsing) and expects value/defaultValue to be an array of open item values rather than one string. You can confirm this yourself by opening node_modules/@base-ui/react/accordion/root/AccordionRoot.d.ts and reading the real, installed type definitions rather than guessing.
The fix is to drop the Radix-specific props entirely, Base UI’s default behavior already matches what this page needs. The value prop on each AccordionItem is unaffected, it is still just a unique identifier for that item:
<Accordion className="mt-8">
{FAQS.map((faq, index) => (
<AccordionItem key={faq.question} value={`item-${index}`}>
<AccordionTrigger>{faq.question}</AccordionTrigger>
<AccordionContent>{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</section>
</main>
<footer className="border-t py-8 text-center text-sm text-zinc-500">
Orbital is a fictional product built for a shadcn/ui tutorial.
</footer>
</div>
);
}
If you specifically want the older Radix-based API (type="single" collapsible), pass --base radix when you initialize the project in Step 3 (npx shadcn@latest init --base radix) instead of accepting the Base UI default. Both are valid, actively maintained choices; this tutorial uses the current recommended default so you can see exactly what a fresh, unmodified init produces today.
Step 10: Build and Verify
Run the production build again:
npm run build
It now succeeds:
✓ Compiled successfully in 3.9s
Running TypeScript ...
✓ Finished TypeScript in 2.2s
Generating static pages using 1 worker (4/4)
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
The ○ (Static) marker means Next.js determined this page has no server-side data dependencies and pre-rendered it to plain HTML at build time, the fastest possible way to serve it.
Step 11: Run It and Confirm the Accordion Actually Works
Start the dev server and open the page:
npm run dev
Visit http://localhost:3000 and click each FAQ question. You should see the answer expand below the question you clicked, and the previously open answer (if any) collapse. This is worth confirming with more than your eyes: by default, Base UI’s accordion panel is not kept in the DOM at all while closed (controlled by a keepMounted prop that defaults to false), rather than merely hidden with CSS. Opening your browser’s developer tools, inspecting the page before clicking any question, and searching for one of the answer strings will confirm it is not present in the DOM yet, it is created only once you open that question, and removed again once you close it. This matters for pages with many long FAQ answers: the browser never has to parse or hold onto markup a visitor has not asked to see yet.
Common Mistakes and Gotchas
- Pasting an accordion (or any component) example from an older tutorial or from memory without checking which base library your project uses. As Step 9 showed, the exact same component name can have a genuinely different prop API depending on whether your project was initialized with Base UI, Radix UI, or React Aria. Always check the real, installed type definitions in
node_modulesfor the component in question when something does not type-check, rather than assuming the example you copied is wrong. - Forgetting to run
addbefore importing a component. Importing from@/components/ui/accordionbefore runningnpx shadcn@latest add accordionfails with a module-not-found error, because unlike a traditional package, nothing exists until the CLI generates that specific file. - Assuming the flags of
create-next-appmatch every version you have seen online. This tutorial’s flags were verified directly againstnpx create-next-app@latest --helpfor the version installed while writing it; flags do change between major versions (for example, skipping git initialization is--disable-git, not--no-git). - Treating
npm auditwarnings from a fresh scaffold as something to fix immediately. A brand-new project can report vulnerabilities in transitive dependencies the moment it is created. Runningnpm audit fix --forcereflexively can downgrade or change packages the framework depends on. Read what is actually flagged before acting on it.
Confirm It All Works End to End
Before considering the page finished, confirm every piece together: run npm run build with a clean exit and no TypeScript errors; run npm run dev and click through every navigation link (#features, #pricing, #faq) to confirm they scroll to the right section; click every FAQ question to confirm each opens and closes independently; and resize your browser window narrower than 640 pixels to confirm the header’s navigation links disappear (the sm:flex behavior from Step 6) without breaking the layout.
Next Steps
- Add a dark mode toggle. The theme variables Step 3 added to
globals.cssalready define both light and dark values for every color; you only need a mechanism (commonly thenext-themespackage) to add or remove adarkclass on the page. - Replace the static “Get started” and “Start free” buttons with a real, working signup form. sxz.io’s tutorial on building type-safe React forms with TanStack Form, Zod, and shadcn/ui covers exactly that, using the same component library.
- Browse the free blocks at shadcn/ui’s own blocks gallery for pre-built sections like sidebars, login screens, and dashboards, generated with the same
npx shadcn add <block-name>pattern you already used for individual components. - Deploy the finished page. Since the build produces a fully static
/route, it can be hosted on Vercel, or any static host, without a running Node.js server.
Sources
- freeCodeCamp: How to Create a Marketing Landing Page Using shadcn/ui, the tutorial that inspired this one
- shadcn/ui: Installation with Next.js (official documentation)
- shadcn/ui: Blocks gallery (official documentation)
- Next.js: Turbopack API Reference, confirming Turbopack became the default bundler in v16.0.0
- Base UI: Accordion component documentation
Image credit: “Rough mobile UX sketch,” Jfietkau, CC0 Public Domain Dedication via Wikimedia Commons; cropped, resized, and converted to WebP for sxz.io.








No Comment! Be the first one.