Skip to content

Repository files navigation

Next.js + Rekey (auth and billing)

A working Next.js app with authentication and billing already wired to Rekey. Sign-up, sign-in, sessions, plans, hosted checkout, entitlements and credits.

It is a starting point, not a framework. Every part of the integration is a short file you can open and read, and there is nothing you have to keep.

  • Next.js 16 (App Router, Turbopack, React 19)
  • Tailwind CSS 4
  • @rekey.dev/nextjs, @rekey.dev/react, @rekey.dev/node

Getting it running

You need a Rekey Application. Either sign up at rekey.dev, or run the whole thing yourself with the open source repo. This starter does not care which; only the API URL changes.

npx create-next-app@latest my-app --example https://github.com/rekey-dev/nextjs-starter
cd my-app
cp .env.example .env.local

Fill in .env.local from Panel → your Application → Developer → API keys:

Variable What it is
REKEY_SECRET Server-only. Full API access for this Application. Never commit it, never import it from a client component.
REKEY_URL https://api.rekey.dev, or your own API if you self-host.
NEXT_PUBLIC_REKEY_PUBLIC_KEY Safe in the browser. Identifies the Application; grants nothing on its own.
NEXT_PUBLIC_REKEY_URL Same API, the browser-visible copy.
NEXT_PUBLIC_APP_URL Where this app is reachable. Checkout returns the user here, so it must be the real origin in production.

Then:

npm run dev

Create an account at /sign-up and you are signed in. The billing pages stay empty until you create a plan, which is the next section.

What is where

File What it does
lib/rekey.ts The server client. Holds the secret key.
app/layout.tsx Reads the session on the server, hands the token to <RekeyProvider>.
proxy.ts A cookie-presence gate at the edge, with the public routes listed.
app/actions/auth.ts Sign up, sign in, sign out.
app/actions/billing.ts Plan click to hosted checkout URL, then redirect.
app/actions/billing-manage.ts Cancel at period end.
app/actions/credits.ts A metered feature, done safely.
app/sign-in, app/sign-up <SignIn> and <SignUp> plus the actions above.
app/pricing <PricingTable> fed by plans read from the API.
app/dashboard A page that guards itself and reads entitlements server-side.
app/account Plan status, cancel, credit balance.

Auth

Three server actions are the entire integration. 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';

<SignIn> and <SignUp> render the form, the OAuth buttons and the error states, and hand you a FormData. If you would rather write your own form, do that; the actions are the part that matters.

Reading the session

import { auth } from '@rekey.dev/nextjs/server';

const session = await auth();      // { user, accessToken } | null
session?.user.email;

In a client component, useUser(), <SignedIn> and <SignedOut> read the same session from the provider. There is no flash of the wrong state on first paint, because the server already put the token into the provider in app/layout.tsx.

Protecting a route

Pages guard themselves:

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

Three lines at the top of the page, and the answer to "does this route need a session" lives in the route.

proxy.ts also runs, and it is worth being clear about what it does and does not do. It checks that a session cookie is present and redirects everyone else to sign-in. It deliberately 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; auth() in the page is the lock. Keep both: the proxy means a page you forget to guard is protected by default, and the page check means an expired or revoked token is caught rather than waved through.

MFA

If the account has two-factor enabled, signIn() returns a challenge instead of a session. app/actions/auth.ts checks outcome.kind and redirects rather than pretending the user is in. Collect the code and call mfaVerify() to finish.

Billing

Create a plan first

Panel, then your Application, then Billing, then Plans. Give it a slug, a price and an interval, then add entitlements: feature flags, numeric limits, or a credit grant. Those entitlements are what your app reads later.

Connect a provider (Stripe, Razorpay, PayPal or Paddle) under Billing then Providers, or checkout will have nothing to redirect to.

Selling

/pricing reads plans from the API, so no prices are hardcoded here. Edit a plan in the panel and the page follows.

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

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

The table posts planSlug to your action, which creates the checkout session:

const { url } = await rekey().billing.createCheckout(session.accessToken, {
  planSlug,
  successUrl: `${appUrl}/dashboard?checkout=done`,
  cancelUrl: `${appUrl}/pricing?checkout=canceled`,
});
redirect(url);

The subscription stays PENDING until the provider webhook confirms payment. Rekey handles that webhook; you do not need an endpoint for it.

Checking what someone is allowed to do

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

Do this on the server. A client-side check is a hint for your UI, not a gate.

One thing worth knowing before you price anything: where two subscriptions grant the same numeric entitlement, the higher value wins, they are not added together. So ten copies of a one-seat plan is not a ten-seat plan. Sell a ten-seat plan.

Credits

Check, do the work, then deduct, in that order, so a failure costs the user nothing:

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 });

Pass something stable as idempotencyKey (a job id, a request id) and a retry becomes a no-op instead of a double charge.

Cancelling

cancelSubscription() defaults to cancelling at period end, so the user keeps what they paid for. A provider-backed subscription therefore stays ACTIVE with cancelAt set, and the provider webhook is what eventually ends it. Read cancelAt, or the cancelsAtPeriodEnd() helper, rather than waiting for status to flip.

Deploying

Set the same environment variables, with NEXT_PUBLIC_APP_URL pointing at your real origin, and add that origin to the Application's allowed origins in the panel. Anywhere that runs Next.js works; there is nothing platform-specific here.

Notes

  • On Next 15, rename proxy.ts back to middleware.ts. Same export, older file convention.
  • Organizations are supported but not used here. Turn them on if a company, rather than a person, is the thing that buys your product. There is a guide.

Licence

MIT. Take it apart.

About

Next.js starter with Rekey auth and billing wired up. Sign-in, sessions, plans, checkout, entitlements and credits.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages