Next.js Pricing Table: 3 Plan Cards and a Monthly/Yearly Toggle

Published: September 8, 2026 · 8 min read

Build a Next.js pricing section: typed plan data, three plan cards, and a monthly/yearly toggle, with code. Plus a quick answer on whether Next.js itself is free.

Build a Next.js pricing table with three plans and one highlight

You are building a SaaS or tool landing page in Next.js and just reached the pricing section. How should the plans be laid out so visitors do not stall, and how do you switch between monthly and yearly? This guide is for exactly that moment.

If you landed here searching for "Next.js pricing": Next.js itself is free, open source software under the MIT license.

What costs money is your hosting, such as a Vercel plan. Check each provider's official pricing page for current numbers. This guide is about building the pricing table on your own site.

Here is the answer. Keep it to three plans and highlight only the one you want people to pick. Put plan details in a data array, and let only the pricing section file be a Client Component to handle the monthly/yearly toggle.

  • Modeling plans as a typed array

  • Code for three plan cards (Server Component)

  • A monthly/yearly toggle, and keeping the layout steady when it switches

  • Mobile layout, accessibility, and common mistakes

Three things every clear pricing table has in common

If visitors reach pricing and cannot decide what to click, everything the hero built up goes to waste. Pricing tables that convert tend to share three traits.

First, no more than three plans. Four or more same-size cards blur the differences and make every option look half-baked. On a landing page, "entry, main, upper" is the shape that works.

Second, exactly one recommendation. When every card has the same border and button, people just look at the cheapest column. A stronger border and a single "Recommended" label on the main plan are enough.

Third, notes sit next to the price. Tax included or not, billed yearly up front or not.

If that lives in the footer, people do not trust the number. Put it in one line right under the price.

The pricing table we are building

From top to bottom, the pricing section looks like this.

  • A short heading and a one-line description

  • A monthly / yearly toggle

  • Three cards (entry on the left, main in the middle, upper on the right)

  • On each card: plan name, one-line description, price, note, 5 to 7 features, a button

  • Only the main card gets a strong border and a "Recommended" label

We are not building a giant feature-by-plan matrix. The first goal is a screen where someone scrolling by can pick one of three.

If you need detailed comparisons, add a table below the cards. That is covered in https://design-layer.com/en/articles/nextjs-comparison-table-guide.

Step 1: Put pricing table plans in data

Decide the data before the markup. Writing separate cards for monthly and yearly means one side eventually goes stale. Give each plan both prices and let the view pick one.

lib/plans.ts
export type Plan = {
    id: string;
    name: string;
    description: string;
    monthly: number; // Price per month when billed monthly
    yearly: number; // Price per year when billed yearly
    features: string[];
    featured?: boolean;
    cta: { label: string; href: string };
  };

  // Sample prices
  export const plans: Plan[] = [
    {
      id: "starter",
      name: "Starter",
      description: "For trying it out solo",
      monthly: 9,
      yearly: 90,
      features: ["3 projects", "Basic templates", "Email support"],
      cta: { label: "Start with Starter", href: "/signup?plan=starter" },
    },
    {
      id: "pro",
      name: "Pro",
      description: "For daily professional use",
      monthly: 29,
      yearly: 290,
      features: ["Unlimited projects", "All templates", "Priority support"],
      featured: true,
      cta: { label: "Start with Pro", href: "/signup?plan=pro" },
    },
    {
      id: "team",
      name: "Team",
      description: "For sharing across a team",
      monthly: 99,
      yearly: 990,
      features: ["Up to 10 members", "Role management", "Invoice billing"],
      cta: { label: "Talk to us about Team", href: "/contact" },
    },
  ];

  export const usd = (n: number) => "$" + n.toLocaleString("en-US");

Store each plan's button destination too

With cta in the data, each plan can link somewhere different. If every button goes to the same contact form, separate cards lose their point. Match the label and destination, for example "Talk to us" only on the top plan.

Step 2: Lay out three pricing table cards

If you only show monthly prices, this can stay a Server Component. One column on mobile, three from md up. Making each card a vertical flex container and giving the feature list flex-1 keeps the buttons aligned at the bottom even when feature counts differ.

components/PricingCards.tsx
import { plans, usd } from "@/lib/plans";

  export function PricingCards() {
    return (
      <section className="px-6 py-24">
        <h2 className="text-center text-3xl font-semibold">Pricing</h2>
        <p className="mt-3 text-center text-zinc-600">Every plan includes a 14-day free trial.</p>
        <div className="mx-auto mt-12 grid max-w-5xl gap-6 md:grid-cols-3">
          {plans.map((plan) => (
            <article
              key={plan.id}
              className={
                "flex flex-col rounded-2xl p-6 " +
                (plan.featured ? "border-2 border-zinc-900" : "border border-zinc-200")
              }
            >
              {plan.featured ? (
                <p className="w-fit rounded-full bg-zinc-900 px-3 py-1 text-xs text-white">Recommended</p>
              ) : null}
              <h3 className="mt-3 text-lg font-semibold">{plan.name}</h3>
              <p className="mt-1 text-sm text-zinc-600">{plan.description}</p>
              <p className="mt-6 text-3xl font-semibold tabular-nums">
                {usd(plan.monthly)}
                <span className="text-sm font-normal text-zinc-500"> /mo</span>
              </p>
              <p className="mt-1 text-xs text-zinc-500">Excl. tax</p>
              <ul className="mt-6 flex-1 space-y-2 text-sm">
                {plan.features.map((f) => (
                  <li key={f}>
                    <span aria-hidden="true">✓ </span>
                    {f}
                  </li>
                ))}
              </ul>
              <a
                href={plan.cta.href}
                className="mt-8 inline-flex justify-center rounded-full bg-zinc-950 py-3 text-sm text-white"
              >
                {plan.cta.label}
              </a>
            </article>
          ))}
        </div>
      </section>
    );
  }

Highlight the main pricing plan with a border and a label

Scaling the main card up with scale-110 looks fine on desktop but overlaps or overflows neighbors on phones. A strong border plus one short label says enough. Three identical cards read as a catalog, not a pricing table.

Replace the sample copy with your own

"14-day free trial" under the heading is just an example. If you have no trial, use a line that helps the decision, such as "2 months free with yearly billing."

Step 3: Add a monthly/yearly toggle to the pricing table

When you want to show the yearly discount, keep the current billing cycle in state (a value the component remembers).

Only the pricing section file runs in the browser, so only that file gets "use client" at the top. The page itself stays rendered on the server and passes the plan data down as props.

components/PricingSection.tsx
"use client";

  import { useState } from "react";
  import { usd, type Plan } from "@/lib/plans";

  type Cycle = "monthly" | "yearly";

  export function PricingSection({ plans }: { plans: Plan[] }) {
    const [cycle, setCycle] = useState<Cycle>("monthly");

    return (
      <section className="px-6 py-24 text-center">
        <h2 className="text-3xl font-semibold">Pricing</h2>
        <div role="group" aria-label="Billing cycle" className="mt-8 inline-flex rounded-full border p-1">
          {(["monthly", "yearly"] as const).map((c) => (
            <button
              key={c}
              type="button"
              aria-pressed={cycle === c}
              onClick={() => setCycle(c)}
              className={
                "rounded-full px-4 py-2 text-sm " +
                (cycle === c ? "bg-zinc-950 text-white" : "text-zinc-600")
              }
            >
              {c === "monthly" ? "Monthly" : "Yearly (2 months free)"}
            </button>
          ))}
        </div>
        <div className="mx-auto mt-12 grid max-w-5xl gap-6 text-left md:grid-cols-3">
          {plans.map((plan) => (
            <article
              key={plan.id}
              className={
                "flex flex-col rounded-2xl p-6 " +
                (plan.featured ? "border-2 border-zinc-900" : "border border-zinc-200")
              }
            >
              <h3 className="text-lg font-semibold">{plan.name}</h3>
              <p className="mt-6 text-3xl font-semibold tabular-nums">
                {usd(cycle === "monthly" ? plan.monthly : plan.yearly)}
                <span className="text-sm font-normal text-zinc-500">
                  {cycle === "monthly" ? " /mo" : " /yr"}
                </span>
              </p>
              <p className="mt-1 text-xs text-zinc-500">
                {cycle === "monthly" ? "Excl. tax, billed monthly" : "Excl. tax, billed yearly up front"}
              </p>
              <ul className="mt-6 flex-1 space-y-2 text-sm">
                {plan.features.map((f) => (
                  <li key={f}>{f}</li>
                ))}
              </ul>
              <a
                href={plan.cta.href + (plan.cta.href.includes("?") ? "&" : "?") + "cycle=" + cycle}
                className="mt-8 inline-flex justify-center rounded-full bg-zinc-950 py-3 text-sm text-white"
              >
                {plan.cta.label}
              </a>
            </article>
          ))}
        </div>
      </section>
    );
  }

  // app/page.tsx (stays a Server Component)
  // import { plans } from "@/lib/plans";
  // <PricingSection plans={plans} />

Keep the layout still when it switches

$29 and $290 have different digit counts. Add tabular-nums to the price so digits keep equal width and the cards do not jiggle. Keep the note to one line in both cycles so card height does not jump on every toggle.

State the yearly saving as a number

"Save with yearly" is hard to compare. Write the result: "2 months free" or "save about 17%". Showing the yearly price divided by 12 as a small per-month figure is another common approach.

Carry the billing cycle into the signup button

Someone who chose yearly and lands on a signup form set to monthly may drop off. The code above appends cycle to the link, joining with & if the href already has a query and ? if not. Read that value on the signup page to preselect the cycle.

Pricing table mobile layout and accessibility

Three columns on desktop become three stacked cards on a phone. Only the first one fits on the first screen, so decide what goes first.

  • To show the main plan first, add order-first md:order-none to its card

  • Do not rely on horizontal scrolling for the three cards. People miss the second and third

  • Write prices as text, not images, so screen readers and search engines get them

  • Make the toggle real buttons and expose the current state with aria-pressed

  • Communicate "Recommended" with text, not just color

Common pricing table mistakes and fixes

  • Putting the cheapest plan in the middle. The main plan gets lost. Entry on the left, main in the middle reads best

  • Listing 20 features. Keep only the lines that differ per plan, and move shared features to an "Included in every plan" line below the cards

  • Using "Learn more" as the button. The job of a pricing table is the next step, so write the action: "Start with Pro", "Talk to us"

  • Three "Contact us for pricing" cards. If you cannot show prices, skip the pricing section altogether

  • Hiding the free plan on another page. It cannot be compared unless it sits next to the paid ones

Copy a finished pricing table instead

The Kinetic Service Pricing page on DesignLayer: check the live preview on the left, then copy the source with Copy for Cursor on the right
The Kinetic Service Pricing page on DesignLayer: check the live preview on the left, then copy the source with Copy for Cursor on the right

Toggle, highlighted plan, checklists, three cards on a dark background. Once you add visual polish, tuning still takes time even after the Step 3 state is in place.

In that case you can run a finished pricing section in the browser first, then bring its React + Tailwind code into your Next.js app.

In the DesignLayer catalog you can check sections in a live preview and hand them to your AI agent with Copy for Cursor. Browsing needs no card, and free source copies are 3 per day (JST). https://design-layer.com/en/components

After that, the job is swapping plan names, prices, features, and buttons. It suits SaaS and subscription landing pages. It does not suit per-project quotes or pricing that depends on seats or usage, where an "estimate" plus a contact form is more honest.

Next.js pricing table: common questions

  • Yes. Next.js is open source under the MIT license. Costs come from hosting, such as Vercel.

    Prices and free tiers change, so check each provider's official page.

Summary: building a pricing table in Next.js

  • Next.js itself is free and open source. Costs come from hosting

  • Keep the pricing table to three plans and highlight one with a border and label

  • Store monthly and yearly prices in the same plan data and pick one in the view

  • Run only the pricing section in the browser, and keep price digits the same width so cards do not jiggle

  • Put notes like tax and billing terms right under the price

Start by rendering the three cards from Steps 1 and 2. Placing an FAQ right after pricing answers the doubts people have once they see the numbers. See https://design-layer.com/en/articles/nextjs-faq-accordion-guide, and for overall SaaS page structure, https://design-layer.com/en/articles/nextjs-saas-landing-page-guide.

Related guides

Next.js Pricing Table: 3 Plan Cards and a Monthly/Yearly Toggle | DesignLayer