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
- The comparison-site use case: every field and endpoint, and the plan for each.
- The card object: all the fields you can show.
- Canadian cards: the same code with
allCards("CA"). - The live comparison demo, to see what a side-by-side looks like.