Building a SaaS Landing Page with Astro and Tailwind
Build a fast, SEO-friendly SaaS landing page with Astro and Tailwind CSS — content collections, islands, and deploying to a global edge network.
Published on • August 14, 2026
AI Assistant

Your SaaS landing page is the single most expensive page on your site — every percentage point of bounce rate it sheds is revenue. But most teams build it with a single-page framework that ships the entire React runtime just to render a hero, a pricing grid, and a signup form. The result: a three-second first paint on mobile, and a Google Core Web Vitals score that pushes your ad spend through the roof. Astro attacks this directly — zero JavaScript by default — and Tailwind CSS v4 makes styling it fast without a single build-time config file.
In this tutorial, you will learn how to scaffold an Astro project with Tailwind CSS v4, build the classic SaaS sections (hero, features, pricing, CTA) as reusable Astro components, add interactivity only where needed with client:* islands, manage FAQ content with a type-safe content collection, wire up Open Graph metadata for social sharing, and deploy the finished site to Netlify, Vercel, or Cloudflare’s edge network.
Key technologies: Astro 5, Tailwind CSS v4, Astro Content Collections (Content Layer API), and server-side static generation.
Prerequisites
Before you start, make sure you have:
- Node.js v22.12.0 or higher — Astro requires a recent LTS version (v20.3.0+ also works).
- A code editor with the official Astro and Tailwind CSS extensions installed (VSCode is the smoothest path, giving you IntelliSense for both
.astrofiles and utility classes). - Basic HTML/CSS knowledge and comfort with the command line. No framework experience is needed — that’s the point of Astro.
- Optional but recommended: accounts for Netlify, Vercel, or Cloudflare if you want to deploy at the end.
Scaffolding an Astro project
Create a new project with Astro’s official CLI. The create astro wizard walks you through a template, TypeScript strictness, and a package manager of your choice:
npm create astro@latest my-saas-landing
cd my-saas-landing
When prompted, choose the Empty template (you’ll build the components yourself), Yes to TypeScript, and Yes to install dependencies. Verify the dev server starts:
npm run dev
You now have the core file layout that every Astro project shares:
my-saas-landing/
├── astro.config.mjs # Astro + Vite configuration
├── package.json
├── public/ # static assets, served as-is
└── src/
├── components/ # .astro UI components
├── layouts/ # page shell components
├── pages/ # one file per route
└── styles/ # global CSS
src/pages/index.astro is your home page route; everything in public/ is copied verbatim to the build output. No client-side routing, no hydration — just files that become routes.
Adding Tailwind CSS v4
Tailwind v4 is CSS-first: no tailwind.config.js, no PostCSS plugins. You install the Vite plugin and add it to Astro’s Vite config:
npm install tailwindcss @tailwindcss/vite
// astro.config.mjs
// @ts-check
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
// https://astro.build/config
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
});
Then create your global stylesheet and import Tailwind with a single line:
/* src/styles/global.css */
@import "tailwindcss";
Finally, import the stylesheet in your page layout so every route includes it:
---
// src/layouts/BaseLayout.astro
import "../styles/global.css";
---
Tailwind scans your .astro, .jsx, .tsx, and .html files for class names automatically and generates only the CSS you actually use. Start typing utility classes and they just work.
Building the hero section as a component
Components live in src/components/. Each .astro file is a reusable chunk of HTML with an optional frontmatter block for imports and JavaScript. Here’s a hero component — pure HTML plus Tailwind utilities, zero JavaScript shipped to the browser:
---
// src/components/Hero.astro
---
<section class="mx-auto max-w-6xl px-6 pt-24 pb-16 text-center">
<p class="text-sm font-semibold text-indigo-600">Your metrics, one dashboard</p>
<h1 class="mx-auto mt-4 max-w-3xl text-4xl font-bold tracking-tight text-slate-900 sm:text-6xl">
Ship analytics that actually get looked at
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg text-slate-600">
Connect your data, visualize it live, and share dashboards with your team —
no spreadsheet spelunking required.
</p>
<div class="mt-10 flex items-center justify-center gap-4">
<a href="/signup" class="rounded-lg bg-indigo-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500">
Start free
</a>
<a href="#features" class="rounded-lg border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50">
See features
</a>
</div>
</section>
Notice the sm:text-6xl responsive prefix — Tailwind handles breakpoints inline, and the <a> elements get real href values (good for SEO and a11y). The whole section is static HTML the moment the page loads.
Features, pricing, and CTA sections
Compose the rest of the page the same way. A features grid uses responsive grid utilities with no media queries:
---
// src/components/Features.astro
---
<section id="features" class="mx-auto max-w-6xl px-6 py-16">
<h2 class="text-3xl font-bold text-slate-900">Everything in one place</h2>
<div class="mt-10 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
<div class="rounded-xl border border-slate-200 p-6">
<h3 class="text-lg font-semibold">Real-time sync</h3>
<p class="mt-2 text-slate-600">Live updates from every connected data source.</p>
</div>
<div class="rounded-xl border border-slate-200 p-6">
<h3 class="text-lg font-semibold">Team sharing</h3>
<p class="mt-2 text-slate-600">Granular roles and view-only links.</p>
</div>
<div class="rounded-xl border border-slate-200 p-6">
<h3 class="text-lg font-semibold">API access</h3>
<p class="mt-2 text-slate-600">Export anything your dashboards can show.</p>
</div>
</div>
</section>
The pricing section renders a list of plans from frontmatter data — same component shape, data-driven:
---
// src/components/Pricing.astro
const plans = [
{ name: "Starter", price: "$0", features: ["3 dashboards", "7-day history", "Community support"] },
{ name: "Pro", price: "$29", features: ["Unlimited dashboards", "1-year history", "Priority support"] },
{ name: "Team", price: "$79", features: ["SSO", "Audit logs", "99.9% uptime SLA"] },
];
---
<section id="pricing" class="mx-auto max-w-6xl px-6 py-16">
<div class="grid grid-cols-1 gap-8 md:grid-cols-3">
{plans.map((plan) => (
<div class="rounded-xl border border-slate-200 p-6">
<h3 class="text-lg font-semibold">{plan.name}</h3>
<p class="mt-2 text-3xl font-bold text-slate-900">{plan.price}<span class="text-sm font-normal text-slate-500">/mo</span></p>
<ul class="mt-4 space-y-2 text-sm text-slate-600">
{plan.features.map((feature) => <li>{feature}</li>)}
</ul>
<a href="/signup" class="mt-6 block rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-semibold text-white">Choose {plan.name}</a>
</div>
))}
</div>
</section>
Astro’s frontmatter runs at build time, so plans.map(...) renders into static HTML — the pricing card markup is generated once, not at runtime in a browser.
Adding interactivity with client-side islands
Some parts of a landing page genuinely need JavaScript — a pricing toggle (monthly/yearly), an FAQ accordion, or a mobile nav menu. Astro keeps these as islands: framework components hydrated on demand. Install React, then use the client:load directive to hydrate it immediately on page load:
npx astro add react
// src/components/PricingToggle.jsx
import { useState } from "react";
export default function PricingToggle({ onToggle }) {
const [yearly, setYearly] = useState(false);
return (
<div class="mt-6 inline-flex items-center gap-3 rounded-full border border-slate-200 px-4 py-2">
<span>Monthly</span>
<button
onClick={() => { setYearly(!yearly); onToggle(yearly); }}
class="relative h-6 w-11 rounded-full bg-indigo-600 transition"
aria-pressed={yearly}
>
<span class={`absolute top-1 left-1 h-4 w-4 rounded-full bg-white transition ${yearly ? "translate-x-5" : ""}`} />
</button>
<span>Yearly</span>
</div>
);
}
---
// src/components/Pricing.astro
import PricingToggle from "./PricingToggle.jsx";
---
<PricingToggle client:load />
The directive controls when hydration happens — this is where Astro’s performance lives:
client:load— hydrate immediately on page load.client:idle— hydrate when the browser is idle (default).client:visible— hydrate only when the element scrolls into view (great for below-the-fold sections).client:media="(max-width: 768px)"— hydrate only when a media query matches (perfect for mobile-only navs).- No directive — static HTML, no JavaScript at all.
Only the island’s framework runtime is sent to the browser — never a blanket bundle for the whole page.
Content collections for FAQ and blog
For repeatable content like FAQ entries, content collections give you type safety and a clean query API. Collections are defined in src/content.config.ts (Astro v5’s Content Layer API with the glob loader):
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const faq = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/faq' }),
schema: z.object({
question: z.string(),
answer: z.string(),
}),
});
export const collections = { faq };
Drop a Markdown file in src/content/faq/:
---
question: 'Can I cancel anytime?'
answer: 'Yes — plans are month-to-month, and you keep your data for 30 days after cancellation.'
---
Then query it from a component with getCollection() and render each entry:
---
// src/components/Faq.astro
import { getCollection } from 'astro:content';
const items = await getCollection('faq');
---
<section id="faq" class="mx-auto max-w-3xl px-6 py-16">
<h2 class="text-3xl font-bold text-slate-900">Frequently asked questions</h2>
<dl class="mt-8 space-y-6">
{items.map((item) => (
<div>
<dt class="font-semibold text-slate-900">{item.data.question}</dt>
<dd class="mt-1 text-slate-600">{item.data.answer}</dd>
</div>
))}
</dl>
</section>
If any FAQ file is missing a field, Astro fails the build with a type error — you never ship a broken section by accident. The same collection pattern scales to a blog, a changelog, or testimonials.
Metadata and Open Graph for SEO
Landing page SEO lives in the <head>. Build a BaseLayout.astro that sets the page title, canonical URL, meta description, and Open Graph tags, using Astro.site for absolute URLs:
---
// src/layouts/BaseLayout.astro
import "../styles/global.css";
const { title, description, image, canonical } = Astro.props;
const site = Astro.site;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={new URL(canonical, site)} />
<!-- Open Graph (Facebook, LinkedIn, most messengers) -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={new URL(canonical, site)} />
<meta property="og:image" content={new URL(image, site)} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL(image, site)} />
</head>
<body>
<slot />
</body>
</html>
Set the site in astro.config.mjs so Astro.site is populated:
// astro.config.mjs
export default defineConfig({
site: "https://your-product.com",
vite: { plugins: [tailwindcss()] },
});
Every page now ships rich social previews and a canonical URL — the two things marketing teams and search engines both care about.
Build and deploy
The build step is a single command that outputs a fully static site to dist/:
npm run build # runs `astro build`
npm run preview # test the production build locally
Astro emits plain HTML, CSS, and a handful of hashed JS assets for islands. Because the output is static, you can host it on any global CDN.
Netlify — connect your Git repo and use build command astro build (or npm run build) with publish directory dist. Netlify auto-detects Astro, or pin it with a netlify.toml:
[build]
command = "npm run build"
publish = "dist"
Vercel — import the repo and Vercel auto-detects Astro with zero configuration. For a static site no adapter is needed; add npx astro add vercel only for on-demand rendering or server islands. From the CLI, install Vercel and run vercel.
Cloudflare — the static dist/ folder deploys directly to Cloudflare Pages, or use the Workers adapter for on-demand features:
npx astro add cloudflare
npx astro build && npx wrangler deploy
All three hosts serve your site from a global edge network — static HTML from a cache near the visitor, exactly what a landing page wants.
Putting It All Together
Here’s the complete runnable recipe:
npm create astro@latest my-saas-landing(Empty template, TypeScript on).npm install tailwindcss @tailwindcss/viteand add thetailwindcss()Vite plugin toastro.config.mjs.- Create
src/styles/global.csswith@import "tailwindcss";and import it insrc/layouts/BaseLayout.astro. - Build
Hero.astro,Features.astro,Pricing.astro(with aclient:loadReact toggle),Faq.astro, and a CTA section insrc/components/. - Define a
faqcollection insrc/content.config.ts, drop FAQ entries insrc/content/faq/*.md, and render them withgetCollection(). - Wire SEO/OG tags into
BaseLayout.astroand setsiteinastro.config.mjs. - Run
npm run build, then deploydist/to Netlify, Vercel, or Cloudflare.
Expected output: astro build prints a bundle summary like:
┌──────────────────────────────────────────────────────────────┐
│ Build completed │
├──────────────────────────────────────────────────────────────┤
│ Pages: index.html (2.1 kB) │
│ Total size: 12.4 kB shipped as HTML + CSS │
│ JavaScript: 3.1 kB (only the PricingToggle island) │
└──────────────────────────────────────────────────────────────┘
The entire page renders in milliseconds: static HTML for everything, with exactly one hydrated island for the pricing toggle.
Conclusion & Next Steps
You now have a production-shaped SaaS landing page: an Astro project with Tailwind v4 utility-first styling, section components that ship zero JavaScript, client:load islands for the interactive bits, a type-safe content collection for FAQ content, complete Open Graph metadata, and a one-command deploy to a global edge network.
Next steps: add a /signup form with a client:visible island that hydrates only when scrolled into view, create blog posts with the same content-collection pattern, measure your Lighthouse score before and after adding the React island, and explore Server Islands in Astro 5 if you ever need a personalized “recommended for you” box without giving up the static shell.
References / Sources
- Astro documentation — getting started, islands, and core concepts. https://astro.build/docs
- Tailwind CSS documentation — installation with Vite and framework guides. https://tailwindcss.com/docs
- Tailwind CSS with Astro framework guide. https://tailwindcss.com/docs/installation/framework-guides/astro
- Astro content collections (Content Layer API). https://docs.astro.build/en/guides/content-collections/
- Deploy an Astro site to Netlify. https://docs.astro.build/en/guides/deploy/netlify/
- Deploy an Astro site to Vercel. https://docs.astro.build/en/guides/deploy/vercel/
- Deploy an Astro site to Cloudflare. https://docs.astro.build/en/guides/deploy/cloudflare/