Empty directory to a signed-in user
QuickstartSeven steps and two environment variables. At the end you have a Next.js app where a person can create an account on a ready-made sign-in form and land on a page only they can see. It runs on the free Rekey Cloud plan, which needs no card, and no payment provider is involved.
Running Rekey on your own server instead? Steps 4 to 7 are the same; what changes is only where the API lives. If the words Application, workspace and end user are not yet distinct to you, read Concepts first. It is short.
Start from a template instead
If you would rather read working code than write it, clone one of these. Each needs the keys from steps 1 to 3 and nothing else.
- Next.js starter
Sign-in, sign-up, magic link, OAuth, password reset, plans, checkout, entitlements and credits.
- Astro starter
The same flows with no client JavaScript.
- Digital shop
A Next.js storefront where owning a product is an entitlement.
Create a free Rekey Cloud account
Go to rekey.dev/sign-up and create an account. On the account page that follows, press Start on the free plan, then Open your workspace. You land in the operator panel, signed in, with nothing to copy or paste.
That account makes you an operator of the workspace. It is not a user of the app you are about to build; those are end users, and they sign in to your app, never to the panel.
Create an Application
In the panel, go to Applications and press + New application. You are asked for a name, a slug and an environment. Keep Development, the default. When you are ready to go live, the same Application can be promoted to production from its Settings tab. Promotion is one-way: there is no moving it back out of production. The free plan includes one production Application plus development and staging ones.
Email and password sign-in is on by default and so is public sign-up, so nothing under Authentication needs changing for this guide.
Copy the secret key
Open your new Application and go to Developer → API keys. Press + New API 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_.
The Publishable key card at the top of the same screen is for signing in from browser code. This guide signs in from the server, so you can leave it for now.
Install the packages and set two variables
npx create-next-app@latest acme --ts --app cd acme pnpm add @rekey.dev/nextjs@^2.2.0 @rekey.dev/react@^2.2.0
@rekey.dev/nextjs handles the session cookies on the server. @rekey.dev/react holds the sign-in and sign-up forms. Put this in .env.local with your own key.
The versions are pinned because step 5 uses rekeyRefreshHandler(), which arrived in 2.2.0-rc.3.
# Server only. Never give these the NEXT_PUBLIC_ prefix. REKEY_URL=https://api.rekey.dev REKEY_SECRET=rp_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
REKEY_URLServerhttps://api.rekey.dev- The same value for every Rekey Cloud workspace. Requests are scoped by the key, not the URL. Self-hosting, it is your own deployment's public origin, or http://localhost:3030 when you run it locally. There is no default.
REKEY_SECRETServerrp_test_… or rp_live_…- Application → Developer → API keys → + New API key. The prefix 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.
That is all this guide needs. Three more variables exist and are listed further down for when you reach for them.
Gate the routes, and give stale sessions a way back
The gate checks for the session cookie and sends anyone without one to /sign-in. It never calls Rekey, so it costs nothing per request, and for the same reason it cannot tell you the token is still good. publicRoutes replaces the default list rather than adding to it, so anything you do not name is protected.
// proxy.ts (middleware.ts on Next 14 and 15, same contents)
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|.*\\..*).*)'],
};The access cookie lasts fifteen minutes and the refresh cookie thirty days, so several times a day a signed-in visitor arrives holding only the second one. They are not signed out, they are stale. A page cannot repair that, because repairing a session writes cookies and Next forbids that during a render. So the gate sends them to a route handler, which is allowed to write, and it expects that handler at /api/rekey/refresh. Without this file those visitors get a 404 instead of their page.
// app/api/rekey/refresh/route.ts
import { rekeyRefreshHandler } from '@rekey.dev/nextjs/server';
export const GET = rekeyRefreshHandler();The handler rotates the session, writes both cookies and sends the visitor back to the page they asked for. It follows next only to a path on your own site, so a crafted link cannot use your domain to bounce someone elsewhere. Writing that check by hand is where most copies go wrong: a path like /..//evil.com passes a starts-with-one-slash test and still lands on another host.
Drop in the sign-in and sign-up forms
<SignIn> and <SignUp> render the whole form, styles included. They post to server actions, so the secret key stays on the server and the tokens go straight into httpOnly cookies.
// app/auth-actions.ts
'use server';
import { redirect } from 'next/navigation';
import {
signIn,
signUp,
signOut,
classifySignInError,
type SignInOutcome,
} from '@rekey.dev/nextjs/server';
function credentials(form: FormData) {
return {
email: String(form.get('email') ?? ''),
password: String(form.get('password') ?? ''),
};
}
export async function signInAction(form: FormData) {
let outcome: SignInOutcome;
try {
outcome = await signIn(credentials(form));
} catch (err) {
const failure = classifySignInError(err);
if (!failure) throw err;
redirect(`/sign-in?error=${failure.code}`);
}
// Only reachable once a user has enrolled in MFA. See mfaVerify().
if (outcome.kind === 'mfa_required') redirect('/sign-in?error=MFA_REQUIRED');
redirect('/dashboard');
}
export async function signUpAction(form: FormData) {
try {
await signUp(credentials(form));
} catch (err) {
const failure = classifySignInError(err);
if (!failure) throw err;
redirect(`/sign-up?error=${failure.code}`);
}
redirect('/dashboard');
}
export async function signOutAction() {
await signOut('/');
}A failed sign-in comes back as a code in the URL. classifySignInError() reads it off any Rekey error, and a small table turns it into a sentence. A busy server is not a wrong password, and showing it as one sends the person to reset a password that was fine.
// app/auth-errors.ts
const MESSAGES: Record<string, string> = {
INVALID_CREDENTIALS: 'Email or password is incorrect.',
EMAIL_ALREADY_EXISTS: 'There is already an account with that email.',
PASSWORD_TOO_SHORT: 'That password is too short.',
PASSWORD_BREACHED: 'That password has appeared in a known breach. Choose another.',
PASSWORD_VERIFY_BUSY: 'Your password was not checked because the server was busy. Try again in a moment.',
API_KEY_INVALID: 'REKEY_SECRET was refused. Check it is a secret key from the same deployment as REKEY_URL.',
};
export function authError(code: string | undefined) {
if (!code) return undefined;
return MESSAGES[code] ?? `Something went wrong (${code}).`;
}// app/sign-in/page.tsx
import { SignIn } from '@rekey.dev/react';
import { signInAction } from '../auth-actions';
import { authError } from '../auth-errors';
export default async function SignInPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
return <SignIn action={signInAction} signUpUrl="/sign-up" error={authError(error)} />;
}// app/sign-up/page.tsx
import { SignUp } from '@rekey.dev/react';
import { signUpAction } from '../auth-actions';
import { authError } from '../auth-errors';
export default async function SignUpPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
return <SignUp action={signUpAction} signInUrl="/sign-in" error={authError(error)} />;
}Protect a page and see who is signed in
// app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { auth } from '@rekey.dev/nextjs/server';
import { signOutAction } from '../auth-actions';
export default async function Dashboard() {
// The proxy already turned away anyone without a cookie. This catches a
// token that has since been revoked or expired.
const session = await auth();
if (!session) redirect('/sign-in');
return (
<main>
<p>Signed in as {session.user.email}</p>
<form action={signOutAction}>
<button type="submit">Sign out</button>
</form>
</main>
);
}Run pnpm dev and open http://localhost:3000/dashboard. You are sent to /sign-in. Follow Create account, sign up, and you land back on the dashboard with your email on it. The same person now appears in the panel under Users → End-users.
If the form shows REKEY_SECRET was refused, the key and the URL do not belong together: a key from a self-hosted deployment pointed at api.rekey.dev, or the reverse.
Self-host instead
Everything above works against your own deployment. Two things change. Step 1 becomes booting the stack, with docker compose --profile full up and a handful of generated secrets, after which you sign up in your own panel rather than on rekey.dev. And REKEY_URL becomes your deployment's origin, http://localhost:3030 locally. Steps 2 to 7 do not change.
The self-hosting guide covers running it in production. The self-host quickstart in the repository goes from a fresh clone to a first API key and a first end user over plain HTTP.
The command-line tool (@rekey.dev/cli, including rekey init) and the read tools of the local MCP server (@rekey.dev/mcp) are self-host only. They call the deployment-wide admin API with SUPER_ADMIN_KEY, which only whoever runs the deployment has. On Rekey Cloud, the panel does what rekey init does, and the hosted operator MCP gives an agent your workspace with your own sign-in.
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, through the Node SDK. Install it and build the client once, in a module you only import from server code.
pnpm add @rekey.dev/node
// lib/rekey.ts (import this from server code only)
import { Rekey } from '@rekey.dev/node';
let client: Rekey | undefined;
// Built on first use. The constructor checks the key, so building it at
// module scope fails `next build` on any machine without REKEY_SECRET.
export function rekey(): Rekey {
client ??= new Rekey({
apiUrl: process.env.REKEY_URL!,
secretKey: process.env.REKEY_SECRET!,
});
return client;
}// app/reports/page.tsx
import { redirect } from 'next/navigation';
import { auth } from '@rekey.dev/nextjs/server';
import { rekey } from '@/lib/rekey';
export default async function ReportsPage() {
const session = await auth();
if (!session) redirect('/sign-in');
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 → Setup → 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 → Setup → 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.
The other environment variables
Nothing else is read from the environment by @rekey.dev/nextjs.
NEXT_PUBLIC_REKEY_URLBrowserThe same origin as REKEY_URL- Only read by rekeyBrowser() from @rekey.dev/nextjs/client, for signing in from a client component. The prefix 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. Shown in full every time and safe in browser JavaScript. Also only read by rekeyBrowser().
REKEY_COOKIE_SECUREServertrue or false, optional- 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 serve a real hostname over plain HTTP.
REKEY_TRUSTED_PROXY_HOPSServerA whole number, optional, default 1- How many proxies in front of the app append to X-Forwarded-For. Sign-in and sign-up forward the visitor's IP to the API's per-IP limits from that header. Set 0 when nothing sits in front of the app, or a visitor can pick a fresh address on every attempt.
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 or Node- 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(), rekeyRefreshHandler(), classifySignInError(). Reads REKEY_URL and REKEY_SECRET.
@rekey.dev/reactClient components- <SignIn>, <SignUp>, <UserButton>, <PricingTable> and the rest of the drop-in components. They render a form and hand it to your server action, so no key reaches the browser.
@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() without the server module, for a client component rendering a failure an action returned.
@rekey.dev/nodeNode- Everything the Next helpers do not cover: billing, credits, usage, licenses, organizations, password reset.
What this guide does not cover
- Finishing an MFA sign-in. MFA is optional and off until a user enrols, so the action above only redirects with
MFA_REQUIRED. Completing it takes a code form that callsmfaVerify(); there is no drop-in component for that step yet. - Password reset and magic links. The methods are configured under Authentication → Methods, and the calls live in
@rekey.dev/node. The Next.js starter has both wired up. - 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, say so.
