왜 Polar.sh + Next.js인가
SaaS를 만드는 인디 개발자에게 가장 중요한 것은 속도입니다. 아이디어를 빠르게 구현하고 시장에 내놓아야 합니다. Next.js의 풀스택 능력과 Polar.sh의 결제 인프라를 결합하면, 결제 시스템 구축에 며칠을 쓰지 않고도 완전한 SaaS를 만들 수 있습니다.
Polar.sh는 Next.js를 공식 지원합니다. SDK가 TypeScript로 작성되어 있어 타입 안전성도 확보됩니다.
프로젝트 설정
SDK 설치
npm install @polar-sh/sdk
환경 변수
# .env.local
POLAR_ACCESS_TOKEN=polar_at_xxxxx
POLAR_ORGANIZATION_ID=org_xxxxx
POLAR_WEBHOOK_SECRET=whsec_xxxxx
NEXT_PUBLIC_URL=http://localhost:3000
Polar 클라이언트 설정
API 호출을 위한 싱글턴 클라이언트를 만듭니다. 서버 사이드에서만 사용하므로 클라이언트 번들에 포함되지 않습니다.
// src/lib/polar.ts
import { Polar } from "@polar-sh/sdk";
export const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
});
가격 페이지 구현
제품과 가격 정보를 Polar API에서 가져와 표시합니다. 서버 컴포넌트에서 직접 호출하므로 별도의 API 라우트가 필요 없습니다.
// src/app/pricing/page.tsx
import { polar } from "@/lib/polar";
export default async function PricingPage() {
const products = await polar.products.list({
organizationId: process.env.POLAR_ORGANIZATION_ID!,
isArchived: false,
});
return (
<main className="mx-auto max-w-4xl px-4 py-16">
<h1 className="text-3xl font-bold text-center mb-12">Pricing</h1>
<div className="grid md:grid-cols-3 gap-6">
{products.result.items.map((product) => (
<PricingCard key={product.id} product={product} />
))}
</div>
</main>
);
}
가격 카드 컴포넌트는 클라이언트 컴포넌트로 만들어 체크아웃 버튼에 클릭 핸들러를 연결합니다.
"use client";
interface PricingCardProps {
product: {
id: string;
name: string;
description: string | null;
prices: Array<{
id: string;
amount: number;
interval: string;
}>;
};
}
export default function PricingCard({ product }: PricingCardProps) {
const monthlyPrice = product.prices.find((p) => p.interval === "month");
const handleCheckout = async () => {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId: monthlyPrice?.id }),
});
const { url } = await res.json();
window.location.href = url;
};
return (
<div className="rounded-xl border border-white/10 p-6">
<h2 className="text-xl font-semibold">{product.name}</h2>
<p className="mt-2 text-sm text-zinc-400">{product.description}</p>
{monthlyPrice && (
<p className="mt-4 text-3xl font-bold">
${monthlyPrice.amount / 100}
<span className="text-sm text-zinc-500">/mo</span>
</p>
)}
<button
onClick={handleCheckout}
className="mt-6 w-full rounded-lg bg-violet-600 py-2 text-sm font-medium hover:bg-violet-500"
>
Get Started
</button>
</div>
);
}
체크아웃 API
체크아웃 세션을 생성하고 Polar의 결제 페이지 URL을 반환합니다.
// src/app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { polar } from "@/lib/polar";
import { auth } from "@/auth";
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.email)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { priceId } = await request.json();
const checkout = await polar.checkouts.create({
productPriceId: priceId,
customerEmail: session.user.email,
successUrl: `${process.env.NEXT_PUBLIC_URL}/dashboard?checkout=success`,
metadata: {
userId: session.user.id,
},
});
return NextResponse.json({ url: checkout.url });
}
웹훅 처리
Polar에서 결제, 구독 변경, 취소 등의 이벤트가 발생하면 웹훅으로 알려줍니다. 웹훅 시그니처를 검증해서 위조된 요청을 차단해야 합니다.
// src/app/api/webhooks/polar/route.ts
import { validateEvent } from "@polar-sh/sdk/webhooks";
import { execute } from "@/lib/db";
export async function POST(request: Request) {
const body = await request.text();
const headers = Object.fromEntries(request.headers.entries());
let event;
try {
event = validateEvent(
body,
headers,
process.env.POLAR_WEBHOOK_SECRET!,
);
} catch {
return new Response("Invalid signature", { status: 401 });
}
switch (event.type) {
case "subscription.created": {
const { metadata, productId } = event.data;
await execute(
"UPDATE table_users SET planId = ?, subscribedAt = NOW() WHERE id = ?",
[productId, metadata.userId],
);
break;
}
case "subscription.updated": {
const { metadata, productId } = event.data;
await execute(
"UPDATE table_users SET planId = ? WHERE id = ?",
[productId, metadata.userId],
);
break;
}
case "subscription.canceled": {
const { metadata } = event.data;
await execute(
"UPDATE table_users SET planId = NULL, subscribedAt = NULL WHERE id = ?",
[metadata.userId],
);
break;
}
}
return new Response("OK");
}
구독 상태 확인
사용자의 현재 구독 상태에 따라 기능을 제한하거나 허용합니다. 서버 사이드에서 DB를 조회하는 방식이 가장 간단합니다.
import { auth } from "@/auth";
import { query } from "@/lib/db";
export async function getUserPlan() {
const session = await auth();
if (!session?.user?.id) return null;
const rows = await query(
"SELECT planId, subscribedAt FROM table_users WHERE id = ?",
[session.user.id],
);
return rows[0]?.planId ?? null;
}
이 함수를 서버 컴포넌트나 API 라우트에서 호출하면 현재 사용자의 플랜을 확인하고 그에 맞는 응답을 반환할 수 있습니다.
고객 포털
Polar는 고객이 직접 구독을 관리할 수 있는 포털을 제공합니다. 결제 수단 변경, 플랜 업그레이드·다운그레이드, 구독 취소를 고객이 직접 처리할 수 있어서 지원 부담이 줄어듭니다.
개발자가 별도로 구독 관리 UI를 만들 필요가 없다는 것이 큰 장점입니다. 고객 포털 링크를 대시보드에 추가하기만 하면 됩니다.
결제 시스템은 제품의 핵심이 아닙니다. Polar.sh 같은 서비스를 활용해서 빠르게 구축하고, 제품 본연의 가치를 만드는 데 시간을 투자하세요.