Starter kit

A Next.js starter with auth and billing already wired up

There is now a Next.js repository you can clone that already has sign-up, sign-in, sessions, a pricing page, hosted checkout, entitlements and credits working. It is not a framework and it does not wrap anything. Every part of the integration is a short file you can open, read and delete.

Terminal

npx create-next-app@latest my-app \
  --example https://github.com/rekey-dev/nextjs-starter

Fill in four environment variables from the panel and run it. The source is at rekey-dev/nextjs-starter.

The starter's home page, listing each file in the integration with a one-line description of what it does.
The landing page is a map of the integration. Six files, and that is the whole thing.

Auth is three functions

The entire auth integration is three server actions. They set the session cookie themselves, so there is nothing to store and nothing to thread through your app.

app/actions/auth.ts

import { signIn, signUp, signOut } from '@rekey.dev/nextjs/server';

export async function signInAction(next: string, formData: FormData) {
  const outcome = await signIn({
    email: String(formData.get('email') ?? ''),
    password: String(formData.get('password') ?? ''),
  });

  // An MFA-enrolled account gets a challenge, not a session. Route them
  // somewhere that can collect the code rather than pretending they are in.
  if (outcome.kind !== 'session') redirect('/sign-in?mfa=1');
  redirect(next);
}

<SignIn> renders the form, the OAuth buttons and the error states, and hands you a FormData. If you would rather write your own markup, do that; the action is the part that matters.

The sign-in page from the starter, showing the drop-in SignIn component with email and password fields and a create account link.
The drop-in component, unmodified. It takes an appearance prop if you want it to look like the rest of your product.

Where route protection lives

Pages guard themselves. Three lines at the top, and the answer to “does this route need a session” is in the route rather than in a regex somewhere else.

app/dashboard/page.tsx

const session = await auth();
if (!session) redirect('/sign-in');

The proxy runs too, and it is worth being precise about what it does. It checks that a session cookie is present and redirects everyone else to sign-in. It never calls Rekey, so it costs nothing per request, and for the same reason it cannot know whether the token is still valid. It is the doormat; the page is the lock. Keep both: the proxy means a page you forget to guard is protected anyway, and the page check catches an expired or revoked token.

A bug we shipped and then fixed

The first version had a bare rekeyMiddleware() with a comment claiming it only refreshed the session. It does the opposite: it never refreshes, and with no publicRoutes it protects everything, including the sign-in page, which redirects to itself. Caught by starting the built server and curling the routes, which is a step worth not skipping.

Billing without prices in your source

The pricing page reads plans from the API, so nothing is hardcoded. Edit a plan in the panel and the page follows.

app/pricing/page.tsx

const plans = await rekey().billing
  .getPlans({ limit: 20 })
  .then((r) => r.items.filter((p) => p.active));

<PricingTable plans={plans} checkoutAction={checkoutAction} />

The table posts a plan slug to your action, which creates the checkout session and redirects. The subscription stays PENDING until the provider webhook confirms payment. Rekey handles that webhook; you do not need an endpoint for it.

Reading what someone is allowed to do is one call, and it belongs on the server:

const { features, creditBalance } = await rekey().billing.getEntitlements(session.accessToken);
if (!features.export_csv) return notAllowed();

Before you price anything

Where two subscriptions grant the same numeric entitlement, the higher value wins. They are not added together. Ten copies of a one-seat plan is not a ten-seat plan; sell a ten-seat plan. Taking the maximum is what keeps an upgrade sane while both subscriptions briefly overlap.

Metered work

Check the balance, do the work, then deduct. In that order, so a failure costs the user nothing. Pass something stable as the idempotency key and a retry becomes a no-op rather than a double charge.

app/actions/credits.ts

const { creditBalance } = await rekey().billing.getEntitlements(session.accessToken);
if (creditBalance < 1) return { ok: false, reason: 'no-credits' };

// ... the work ...

await rekey().credits.consume({ endUserId: session.user.id, amount: 1, idempotencyKey });

There are two others

The same idea in Astro, and a digital shop where what you own is an entitlement rather than a row somebody has to keep in sync. Both are linked below.

A Next.js starter with auth and billing already wired up | Rekey