Build a credit card comparison site with Next.js

About a hundred lines of code: a list of every US card, a page per card and a side-by-side comparison, all rebuilt in the background so visitors never wait on the API. It runs on your own CardAPI key; nothing in it ships card data.

What you need

  • Node.js 20 or later and a Next.js 15+ app (App Router).
  • A CardAPI key. Every plan works for building: names, fees, welcome offers and each card's top 3 reward rates. The site below reads the whole card list once a day and every page shares it, about 4 calls a day, well inside Free's 100 a day. Starter plan and above ($29/mo, 7-day free trial) adds every rate, benefits, APRs and apply links, which a real comparison site shows.
  • Data fetched during the trial is for evaluation only: build on it, and go live once the plan is paid (terms).

1. Create the app and add your key

Start a Next.js app (App Router) and install the TypeScript SDK. Put your CardAPI key in .env.local; it is read only on the server, so it never reaches the browser.

Terminal

npx create-next-app@latest card-compare --ts --app
cd card-compare
npm install @cardapi/client
echo "CARDAPI_API_KEY=ck_live_..." >> .env.local

2. One server-side client

Every page imports the client from one module. The server-only import makes the build fail if a client component ever imports it, which would leak the key.

The whole list is fetched once and cached for a day, and every page reads that one copy. A card page never calls the API on its own: one call per card would be a few hundred calls per build, more than the Free plan's daily limit.

lib/cardapi.ts

import "server-only";
import { unstable_cache } from "next/cache";
import { CardAPI, type Card } from "@cardapi/client";

export const api = new CardAPI({ apiKey: process.env.CARDAPI_API_KEY! });

// Every published card in one country, 100 a page. The list already carries
// each card's reward rates, benefits and fees, so the card pages read it too.
// Cached for a day and shared by every page: about 4 calls a day for ~350
// US cards, however many pages you build or people visit.
export const allCards = unstable_cache(
  async (country: "US" | "CA"): Promise<Card[]> => {
    const cards: Card[] = [];
    for (let offset = 0; ; offset += 100) {
      const page = await api.getCards({ country, limit: 100, offset });
      cards.push(...page.data);
      if (cards.length >= page.total || page.data.length === 0) return cards;
    }
  },
  ["cardapi-all-cards"],
  { revalidate: 86400 } // seconds: one refresh a day
);

export async function cardBySlug(country: "US" | "CA", slug: string): Promise<Card | undefined> {
  return (await allCards(country)).find((card) => card.slug === slug);
}

3. The card list, rebuilt once a day

The list page is static and regenerated in the background at most once a day (incremental static regeneration). A visitor never waits on the API, and with the cached list from step 2 the whole site costs about 4 calls a day (one per 100 cards) however many pages it has or people visit.

CardAPI re-checks every card against the issuer's site every Friday, so a daily rebuild is never more than a day behind.

app/cards/page.tsx

import Link from "next/link";
import { allCards } from "@/lib/cardapi";

export const revalidate = 86400; // seconds: rebuild at most once a day

export default async function CardsPage() {
  const cards = await allCards("US");
  return (
    <ul>
      {cards.map((card) => (
        <li key={card.slug}>
          <Link href={`/cards/${card.slug}`}>{card.name}</Link>
          {" "}({card.issuers.name}, annual fee {card.annual_fee ?? "unknown"})
        </li>
      ))}
    </ul>
  );
}

4. A page per card

Pre-render every card at build time and regenerate each one daily, all from the cached list, so a build of a few hundred pages costs the same few calls. A card that is added later shows up once the list refreshes, rendered on its first visit. Starter and above return every reward rate, the benefits and the apply link; Free returns the top 3 rates.

app/cards/[slug]/page.tsx

import { notFound } from "next/navigation";
import { allCards, cardBySlug } from "@/lib/cardapi";

export const revalidate = 86400;

export async function generateStaticParams() {
  return (await allCards("US")).map((card) => ({ slug: card.slug }));
}

export default async function CardPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  // From the cached list: no API call per card. A failed list read throws,
  // so the last good page stays up instead of an error being cached.
  const card = await cardBySlug("US", slug);
  if (!card) notFound();

  return (
    <article>
      <h1>{card.name}</h1>
      <p>{card.signup_bonus}</p>
      <ul>
        {(card.reward_rates ?? []).map((r) => (
          <li key={r.id}>
            {r.rate_multiplier}
            {r.rate_type === "percent_cashback" ? "%" : "x"} on {(r.categories ?? []).join(", ") || "everything else"}
          </li>
        ))}
      </ul>
      {card.apply_url && <a href={card.apply_url}>Apply on the issuer's site</a>}
    </article>
  );
}

5. Side by side

The comparison page reads the cards from the URL (/compare?cards=a,b), so every comparison has a link people can share. It is rendered per request; GET /v1/cards/compare takes 2 to 5 slugs and returns them in the order asked for, so one view is one call.

app/compare/page.tsx

import { api } from "@/lib/cardapi";

export default async function ComparePage({ searchParams }: { searchParams: Promise<{ cards?: string }> }) {
  const slugs = ((await searchParams).cards ?? "").split(",").filter(Boolean).slice(0, 5);
  if (slugs.length < 2) return <p>Pick two to five cards.</p>;
  const cards = await api.compareCards(slugs);

  return (
    <table>
      <thead>
        <tr><th />{cards.map((c) => <th key={c.slug}>{c.name}</th>)}</tr>
      </thead>
      <tbody>
        <tr><th>Annual fee</th>{cards.map((c) => <td key={c.slug}>{c.annual_fee ?? "unknown"}</td>)}</tr>
        <tr><th>Welcome offer</th>{cards.map((c) => <td key={c.slug}>{c.signup_bonus ?? "none"}</td>)}</tr>
        <tr><th>Foreign transaction fee</th>{cards.map((c) => <td key={c.slug}>{c.foreign_transaction_fee ?? "unknown"}</td>)}</tr>
      </tbody>
    </table>
  );
}

6. Optional: rebuild a page when a card changes

On Enterprise, register a webhook for card.updated and card.deleted and rebuild just that card's page when one arrives, instead of waiting for the daily rebuild. Check the X-CardAPI-Signature header against the secret you got when you registered the webhook.

app/api/cardapi-webhook/route.ts

import crypto from "node:crypto";
import { revalidatePath } from "next/cache";

export async function POST(req: Request) {
  const body = await req.text();
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.CARDAPI_WEBHOOK_SECRET!).update(body).digest("hex");
  const given = req.headers.get("x-cardapi-signature") ?? "";
  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return new Response("bad signature", { status: 401 });
  }
  const { data } = JSON.parse(body) as { event: string; data: { slug?: string } };
  if (data.slug) revalidatePath(`/cards/${data.slug}`);
  revalidatePath("/cards");
  return new Response("ok");
}

Caching and display rules

  • Static pages and ISR are caching, and caching is allowed on every active plan.
  • Show the data on your pages; don't offer it raw as a download, a feed or an API of your own. The backend in this guide is only called by your own pages, which is fine.
  • If your plan ends, take down or rebuild what you stored from it within 30 days.

The rules in full: Terms of Service.

Next