Empty directory to a gated feature
QuickstartNine steps, in order. At the end you have a Next.js app where a person can create an account, land on a page only they can see, and be refused a feature they have not been granted. No payment provider is involved at any point.
If the words Application, workspace and end user are not yet distinct to you, read Concepts first. It is short.
Get a Rekey to point at
You need an API running somewhere. Either start one locally with docker compose --profile full up, which brings up the API on http://localhost:3030 along with Postgres, Redis and the operator panel (the self-hosting guide has the details), or use Rekey Cloud, whose API is https://api.rekey.dev for every workspace.
Either way you sign in to the operator panel as an operator. That account is not a user of the app you are about to build.
Create an Application
In the panel, go to Applications and press + New application. You are asked for a name, a slug and an environment. Development is the default, and the environment cannot be changed afterwards, so going to production later means a second Application rather than a switch on this one.
Email and password sign-in is on by default and so is public sign-up, so you do not have to configure anything under Authentication to finish this guide.
Copy the two keys
Open your new Application and go to Developer → API keys. Two things live on that screen.
The Publishable key card at the top shows a rp_pub_… value in full, with a Copy button. It identifies the Application to a browser and authorizes nothing.
Below it, + New API key mints a secret key. Give it a name, leave permissions on Full access, and copy the value from the banner that says it is shown once. It is stored as a hash and there is no way to see it again. On a Development or Staging Application it starts rp_test_; on a Production one, rp_live_.
Write the environment file
Create the app and install the packages. The Next helpers cover auth; the Node SDK covers everything else.
npx create-next-app@latest acme --ts --app cd acme pnpm add @rekey.dev/nextjs @rekey.dev/node
Then put this in .env.local, with your own values.
# Server only. Never give these the NEXT_PUBLIC_ prefix. REKEY_URL=http://localhost:3030 REKEY_SECRET=rp_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Sent to the browser. Safe to publish. NEXT_PUBLIC_REKEY_URL=http://localhost:3030 NEXT_PUBLIC_REKEY_PUBLIC_KEY=rp_pub_acme-dev_xxxxxxxxxxxxxxxx
REKEY_URLServerThe API origin- On Rekey Cloud, https://api.rekey.dev. Self-hosting, your own deployment's public origin, and http://localhost:3030 when you are running it locally. There is no default and the panel does not print it anywhere.
REKEY_SECRETServerrp_live_… or rp_test_…- Application → Developer → API keys → + New API key. Which prefix you get follows the Application's environment and is not a choice at mint time. The raw key is shown once, so copy it before you leave the page.
NEXT_PUBLIC_REKEY_URLBrowserThe same origin as REKEY_URL- The same value. The prefix only tells the bundler it may be inlined into client JavaScript.
NEXT_PUBLIC_REKEY_PUBLIC_KEYBrowserrp_pub_<slug>_…- Application → Developer → API keys, the Publishable key card at the top. It is shown in full every time, not once, and it is safe in browser JavaScript.
REKEY_COOKIE_SECUREServertrue or false, optional- Not a panel value. Leave it unset. The SDK decides per request from X-Forwarded-Proto and the host, and treats anything that is not loopback as internet facing. Set it to false only if you deliberately want session cookies without Secure on a real host.
That is the whole list. Nothing else is read from the environment by @rekey.dev/nextjs.
Know which import path to use
The package is deliberately split, and importing from the wrong entry is the fastest way to a build error. The root @rekey.dev/nextjs re-exports the server module, so a client component that imports from it will fail to bundle.
@rekey.dev/nextjs/middlewareEdge- rekeyMiddleware(). Needs no key: it checks that the cookie is present, not that it is valid.
@rekey.dev/nextjs/serverNode- auth(), signIn(), signUp(), signOut(), mfaVerify(), createSession(), refreshSession(). Reads REKEY_SECRET.
@rekey.dev/nextjs/clientBrowser- rekeyBrowser(), for signing in from a client component with the publishable key.
@rekey.dev/nextjs/cookiesAnywhere- ACCESS_COOKIE, REFRESH_COOKIE and their options. Import the cookie names from here, never from the package root, which pulls the server module in with them.
@rekey.dev/nextjs/errorsAnywhere- classifySignInError(), so a device limit or a busy password check is not rendered as a wrong password.
@rekey.dev/nodeNode- Everything the Next helpers do not cover: billing, credits, usage, licenses, organizations.
Gate the routes, and give stale sessions a way back
The middleware checks for the access cookie and redirects anyone without one. It never calls Rekey, so it costs nothing per request, and for the same reason it cannot tell you the token is still good. Note that publicRoutes replaces the default list rather than adding to it: anything you do not name is protected.
// middleware.ts
import { rekeyMiddleware } from '@rekey.dev/nextjs/middleware';
export default rekeyMiddleware({
signInUrl: '/sign-in',
publicRoutes: ['/', '/sign-in', '/sign-up'],
});
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};Now the part that is easy to miss. The access cookie lasts fifteen minutes and the refresh cookie lasts thirty days, so several times a day every signed-in visitor arrives holding one and not the other. They are not signed out, they are stale. A page cannot fix it, because repairing a session writes cookies and Next forbids that during a render. So the middleware sends them to a route handler, which is allowed to write, and it expects that handler at /api/rekey/refresh unless you name another one. If the file does not exist, those visitors get a 404 instead of their page.
// app/api/rekey/refresh/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { refreshSession } from '@rekey.dev/nextjs/server';
export async function GET(req: NextRequest) {
await refreshSession();
const next = req.nextUrl.searchParams.get('next') ?? '/';
// Only ever bounce back into this app.
const target = next.startsWith('/') ? next : '/';
return NextResponse.redirect(new URL(target, req.nextUrl.origin));
}Sign somebody in
Server actions, so no Application key ever reaches the browser. The tokens go straight into httpOnly cookies.
// app/sign-in/page.tsx
import { redirect } from 'next/navigation';
import { signIn, signUp } from '@rekey.dev/nextjs/server';
export default function SignInPage() {
async function signInAction(formData: FormData) {
'use server';
const outcome = await signIn({
email: String(formData.get('email')),
password: String(formData.get('password')),
});
// MFA is optional by default, so this branch only fires once a user
// has enrolled. Collect a code and finish with mfaVerify().
if (outcome.kind === 'mfa_required') redirect('/sign-in?error=mfa');
redirect('/dashboard');
}
async function signUpAction(formData: FormData) {
'use server';
await signUp({
email: String(formData.get('email')),
password: String(formData.get('password')),
});
redirect('/dashboard');
}
return (
<form action={signInAction}>
<input name="email" type="email" autoComplete="email" required />
<input name="password" type="password" autoComplete="current-password" required />
<button type="submit">Sign in</button>
<button type="submit" formAction={signUpAction}>Create an account</button>
</form>
);
}Before you render a failure, run the error through classifySignInError() from @rekey.dev/nextjs/errors. A device limit and a busy password check are not wrong passwords, and showing them as one leaves the person with nothing to try.
Then a page that only they can see.
// app/dashboard/page.tsx
import { auth } from '@rekey.dev/nextjs/server';
export default async function Dashboard() {
const session = await auth();
// The middleware already redirected anyone without a cookie. This catches
// a token that has since been revoked or expired.
if (!session) return null;
return <p>Signed in as {session.user.email}</p>;
}At this point you have a working sign-in. Create an account through the form, and the new end user appears in the panel under Users → End-users.
Gate a feature, with no payment provider
An entitlement is what a plan grants. Reading one is a server-side call that takes the end user's access token, so build the client once in a module you only import from server code.
// lib/rekey.ts (import this from server code only)
import { Rekey } from '@rekey.dev/node';
export const rekey = new Rekey({
apiUrl: process.env.REKEY_URL!,
secretKey: process.env.REKEY_SECRET!,
});// app/reports/page.tsx
import { auth } from '@rekey.dev/nextjs/server';
import { rekey } from '@/lib/rekey';
export default async function ReportsPage() {
const session = await auth();
if (!session) return null;
const { features, creditBalance } = await rekey.billing.getEntitlements(
session.accessToken,
);
if (features.advanced_reporting !== true) {
return <p>Your plan does not include advanced reporting.</p>;
}
return <Reports creditBalance={creditBalance} />;
}To make that flag true for a test account, without connecting Stripe, PayPal or Razorpay, do four things in the panel.
- Billing is off on a new Application. Go to Billing → Providers and press Enable billing. You do not have to add a provider; the plan screens just need the switch on. While billing is off the Billing group in the nav is marked off and takes you to that same screen.
- Go to Billing → Plans, press + New plan, pick Subscription, and give it a slug such as
pro-monthly. The amount does not matter here, because nobody is going to pay it. - On that plan press Entitlements. Choose kind Feature flag / limit, set Key to
advanced_reporting, Type to Boolean, Value totrue, and Save entitlement. - Go to Users → End-users, open your test account, open its Subscriptions tab, and press Grant subscription. Pick the plan, write a reason (it is recorded in the audit log), leave the end date blank, and confirm. No money is collected and no provider is contacted.
Reload the page and the gate opens. Revoke the grant and it closes again.
There is a second route worth knowing. An Application can nominate a default plan, and its feature flags and included usage are applied at read time to everybody, with no subscription at all. That is how a free tier works. Credits and licenses are stateful and still need a real subscription, granted by hand or bought. The default plan has no form in the panel today: it is billingConfig.defaultPlanSlug, set with a PATCH to the Application's billing config.
Take money, when you are ready to
Connect a provider under Billing → Providers, then send the buyer to a hosted checkout. planSlug, successUrl and cancelUrl are all required, and the two URLs must be absolute. Leaving either out is a validation error, not a default.
// app/billing/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { auth } from '@rekey.dev/nextjs/server';
import { rekey } from '@/lib/rekey';
const APP_URL = 'https://your-app.example.com';
export async function startCheckout(planSlug: string) {
const session = await auth();
if (!session) redirect('/sign-in');
const { url } = await rekey.billing.createCheckout(session.accessToken, {
planSlug,
// Both are required and both must be absolute URLs.
successUrl: `${APP_URL}/billing?checkout=ok`,
cancelUrl: `${APP_URL}/billing?checkout=cancelled`,
});
redirect(url);
}The subscription stays PENDING until the provider webhook confirms the payment. Rekey receives that webhook, so you do not need an endpoint for it. If the Application bills organizations rather than individuals, pass organizationId as well, or the call is refused with BILLING_ORGANIZATION_REQUIRED.
What this guide does not cover
- MFA, password reset and magic links. The methods are configured under Authentication → Methods, and the calls live in
@rekey.dev/node. - Signing in from a client component with the publishable key. That path uses
@rekey.dev/nextjs/clientand hands the resulting tokens to a route handler that callscreateSession(). - Setting
billingConfig.defaultPlanSlugfrom the panel. There is no screen for it yet. - Outbound webhooks to your own backend, which have their own page.
Every method and signature is in the SDK reference, and every endpoint in the API reference. If something here does not match what you see, tell us.
