Skip to main content
ASoc
Tutorial

Building a SaaS Landing Page in Next.js 16 That Loads Fast

Static rendering, LCP on the hero, and a small client bundle — plus the accessibility bugs our own Lighthouse audit caught that code review missed.

The ASoc Team12 min read

A SaaS landing page in Next.js 16 should be statically rendered, ship almost no JavaScript, and treat the hero image as the LCP element it actually is. The conversion work — clear value proposition, one primary action, proof near the ask — matters more than the framework. But a slow landing page loses the visitor before the copy gets a chance.

This post covers both halves: the structure that converts, and the Next.js implementation that keeps it fast. The performance numbers and bugs below are from auditing our own marketing pages, not from a checklist.

Render it statically, and check that you did

A marketing page has no per-request data. It should be built once and served from a CDN.

In the App Router this is the default — a page with no dynamic APIs is statically rendered at build. The problem is how easily you lose it by accident. Reading cookies(), headers(), or searchParams anywhere in the tree opts the whole route into dynamic rendering, and nothing warns you.

Verify rather than assume. npm run build prints the render mode per route:

Route (app)                    Size     First Load JS
┌ ○ /                          4.2 kB          98 kB
└ ○ /pricing                   3.1 kB          96 kB

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

If your landing page shows ƒ, find the dynamic API and remove it. A/B testing and geolocation are the usual culprits — move both to middleware or a client component so the page itself stays static.

The hero is your LCP element

Largest Contentful Paint on a landing page is almost always the hero image or headline. Everything about how you load the hero is a performance decision.

import Image from "next/image";

<Image
  src="/images/hero.webp"
  alt="The dashboard, showing revenue and active users"
  width={1200}
  height={720}
  priority
  fetchPriority="high"
  sizes="(max-width: 768px) 100vw, 1200px"
/>;

Four things are load-bearing here:

  • priority preloads the image and disables lazy loading. Without it the browser discovers the hero late, after CSS and JS. This is the single highest-impact change on most landing pages.
  • width/height reserve the space so the layout does not shift when the image arrives.
  • sizes stops mobile downloading the desktop-sized file.
  • A real alt. Describe what the image shows, not "hero image". It is an accessibility requirement and it is read by search crawlers.

Use priority on the hero and nothing else. Marking six images priority preloads six images, which competes for bandwidth and makes LCP worse than marking none.

For fonts, next/font self-hosts and preloads automatically, which removes a render-blocking round trip to a font CDN:

import { Outfit } from "next/font/google";

const outfit = Outfit({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-outfit",
});

display: "swap" renders text in a fallback immediately rather than leaving it invisible. On a landing page, text visible at 400ms in the wrong font beats text visible at 1.2s in the right one.

Keep the client bundle small by default

In the App Router everything is a Server Component unless you say otherwise, and a landing page is almost entirely static markup. The interactive parts are few:

  • the mobile menu toggle
  • an FAQ accordion
  • a pricing monthly/annual switch
  • form inputs

That is it. Each should be its own small client component, with the rest of the page — hero, features, testimonials, footer — staying on the server.

The mistake that erases the benefit is putting "use client" at the top of the page or layout. That makes every child a client component, and you have shipped the entire landing page as JavaScript to save yourself one import.

// app/page.tsx — Server Component
import Hero from "@/components/Hero";
import PricingToggle from "@/components/PricingToggle"; // "use client" lives here

export default function Home() {
  return (
    <>
      <Hero />
      <PricingToggle />
    </>
  );
}

What our own audit actually found

We took our marketing pages to Lighthouse 100 on accessibility and SEO, and 99–100 on performance. The interesting part was not the score — it was that four of the five real problems were accessibility, not speed, and two were invisible in code review.

ProblemWhy it happenedFix
Newsletter input rendered solid white, 2.57:1 contrastbg-opacity-5 is a dead utility in Tailwind v4 — it silently did nothing, so the intended translucent pill rendered opaquebg-white/5
Brand-tinted text failed AA at 4.33:1 and 4.49:1text-primary is tuned for buttons, not small text on whitetext-primary-600 on those elements only
Success badges at 3.53:1Same cause, green scaletext-success-700
Heading levels skippedCard titles were h3 under an h2-less sectionCard title → h2, visual classes unchanged
Link's accessible name mismatched its visible textAn aria-label overrode the visible "Read more"Removed the aria-label

Two lessons generalize.

A migrated utility can fail silently. bg-opacity-5 was valid in v3 and does nothing in v4. It produced no error and no warning — just the wrong color, which nobody noticed until a contrast audit measured it. If you migrated from v3, audit contrast rather than trusting the diff. The v4 migration guide covers which utilities were removed.

Your brand color is probably not an accessible text color. A primary tuned to look good as a button background is usually too light for 16px text on white. Use one shade darker for text and keep the original for fills. This is a two-token change, not a rebrand.

Fix heading order for real, too: one h1 per page, no skipped levels. Screen reader users navigate by heading, and it is the cheapest structural signal search engines read.

Metadata and structured data

The App Router's metadata API replaces next/head and runs on the server:

export const metadata: Metadata = {
  title: "Ship your dashboard this week",
  description:
    "A production-ready admin template in Next.js and Tailwind v4. 60+ components, dark mode, TypeScript.",
  alternates: { canonical: "/" },
  openGraph: {
    title: "Ship your dashboard this week",
    description: "A production-ready admin template in Next.js and Tailwind v4.",
    images: ["/opengraph-image.png"],
    type: "website",
  },
};

Write the description for a human deciding whether to click. It is not a ranking factor, it is the copy in the search result, and it competes with nine others.

For a SaaS product, add SoftwareApplication JSON-LD so the offer is machine-readable:

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
    __html: JSON.stringify({
      "@context": "https://schema.org",
      "@type": "SoftwareApplication",
      name: "Your Product",
      applicationCategory: "BusinessApplication",
      offers: { "@type": "Offer", price: "39", priceCurrency: "USD" },
    }),
  }}
/>

This matters more than it used to. AI search engines parse structured data to answer product questions, and an unparseable price gets filtered out of comparisons entirely. If a model cannot determine what you cost, it recommends something it can.

Structure that converts

The technical work buys attention. The page still has to earn the click.

  1. Headline stating the outcome, not the category. "Ship your dashboard this week" beats "Modern admin template."
  2. One primary CTA, repeated. Not three competing buttons — one action, appearing in the hero, mid-page, and at the end.
  3. A product screenshot above the fold. For developer tools, showing the thing outperforms describing it.
  4. Proof near the ask. Logos, counts, testimonials — placed next to the CTA, not stranded in a section nobody scrolls to.
  5. Features as outcomes. "Dark mode on every page" is a feature; "your users stop complaining about 2am glare" is why they care.
  6. Objection-handling FAQ. Write the four questions that actually block purchase. Licensing and "does it work with X" are usually two of them.
  7. A close that restates the offer with the price and the CTA.

Order matters less than the discipline of one page, one action.

Measure the click, not the session

Sessions and time-on-page will not tell you whether the page works. Instrument the CTA:

"use client";
import { track } from "@vercel/analytics";

export default function CtaButton({ location }: { location: string }) {
  return (
    <a href="/pricing" onClick={() => track("cta_clicked", { location })}>
      Get started
    </a>
  );
}

Passing location tells you which CTA converts — hero, mid-page, or footer. That is the difference between "the page converts at 3%" and "nobody scrolls past the hero, so move the proof up."

Cookieless analytics handles this without a consent banner, which is worth real conversion points on its own.

Mistakes and how they show up

MistakeSymptomFix
"use client" on the pageHuge First Load JSPush it to the interactive leaf
No priority on the heroLCP over 2.5spriority + fetchPriority="high"
priority on every imageLCP gets worseHero only
Missing width/heightLayout shifts as images loadAlways set both
Accidental dynamic renderingƒ in build outputRemove cookies()/headers() from the tree
Brand color as small textFails WCAG AAOne shade darker for text
Dead v3 utilities after migrationSilently wrong colorsAudit contrast after migrating
Three competing CTAsNobody picks oneOne primary action, repeated
Font from a CDNRender-blocking round tripnext/font self-hosts

Frequently asked questions

Static or server-rendered for a landing page? Static. There is no per-request data, and a prerendered page served from a CDN is the fastest thing you can ship. Use dynamic rendering only for genuinely per-visitor content, and put personalization in a client component so the shell stays static.

Does a fast landing page actually convert better? Speed is a floor, not a lever. A page that takes four seconds loses visitors before they read anything, so fixing that recovers real conversions. Going from 1.2s to 0.9s will not move your rate — the copy and the offer will. Fix slow, then stop optimizing and go write better copy.

Do I need a consent banner? It depends on your analytics. Cookieless analytics that stores no personal data generally does not require one in the EU. Anything setting tracking cookies does. Since the banner itself costs conversions, cookieless is worth choosing for that reason alone — but confirm against your jurisdiction and your actual vendor.

Where should the pricing go? On the landing page if you have simple, public pricing — hiding it filters out buyers and, increasingly, gets you skipped by AI tools that compare products. Link out to a dedicated page when tiers need real explanation.

How do I A/B test without breaking static rendering? Split traffic in middleware, or swap a client-side variant after hydration. Do not read cookies in the page component — that makes the whole route dynamic and throws away the CDN.

Starting from a finished one

The structure above is roughly a week of work to build well, and most of that week goes to the parts nobody sees: contrast, heading order, image sizing, metadata.

Our Next.js landing page templates ship that work done — statically rendered, Tailwind v4 tokens, dark mode, metadata and OG wired up. ASoc Flow is a workflow-automation SaaS page with a visual flow-builder section, ASoc Vault is a fintech marketing site with a tiered pricing page, and ASoc Echo is built around an AI chatbot product with a live widget preview.

Browse all Next.js landing page templates or the Tailwind landing page templates if the framework matters less than the design system.

Keep reading

Tutorial11 min read

Multi-Tenant Theming with Tailwind CSS v4 and CSS Variables

Tailwind v4 tokens compile to real CSS custom properties, so one build can serve every tenant's brand. The override pattern, contrast handling, and the pitfalls.

Read more
Tutorial12 min read

How to Build an Admin Dashboard with Next.js 16 and Tailwind CSS v4

A working admin dashboard in Next.js 16 and Tailwind CSS v4 — App Router layouts, a CSS-first theme, an accessible sidebar, and the server/client split that keeps it fast.

Read more