Documentation
Build withFramework quickstartsNext.js

Next.js

Two clients, split so the secret key never reaches the browser.

Next.js runs your code in two places, so you create two clients. The split is not a workaround — it is the same distinction as publishable and secret keys.

Install

npm install @nulvless/nulvless-js

Environment

.env.local
NEXT_PUBLIC_NULVLESS_URL=https://<project>.nulvless.co
NEXT_PUBLIC_NULVLESS_KEY=pk-…
NULVLESS_SECRET=sk-…

Only the NEXT_PUBLIC_ values reach the browser. NULVLESS_SECRET must never gain that prefix.

The browser client

lib/nulvless.ts
'use client';

import { createClient, Storage } from '@nulvless/nulvless-js';

export const nulvless = createClient(
  process.env.NEXT_PUBLIC_NULVLESS_URL!,
  process.env.NEXT_PUBLIC_NULVLESS_KEY!,
  { storage: Storage.Local, trackUser: true },
);

The server client

lib/nulvless-server.ts
import 'server-only';

import { createServerClient } from '@nulvless/nulvless-js';

export const nulvlessServer = createServerClient(
  process.env.NEXT_PUBLIC_NULVLESS_URL!,
  process.env.NULVLESS_SECRET!,
);

server-only makes a mistake fail at build time rather than shipping the secret key to the browser.

Sign in from a client component

app/sign-in/page.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { nulvless } from '@/lib/nulvless';

export default function SignIn() {
  const router = useRouter();
  const [error, setError] = useState<string | null>(null);

  async function submit(formData: FormData) {
    const { data, error } = await nulvless.auth.signIn({
      email: String(formData.get('email')),
      password: String(formData.get('password')),
    });

    if (error) return setError('Check your email and password.');
    if (data.challenge) return router.push('/sign-in/verify');

    router.push('/dashboard');
  }

  return (
    <form action={submit}>
      <input name="email" type="email" />
      <input name="password" type="password" />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Sign in</button>
    </form>
  );
}

Sign-in belongs on the client: the SDK stores the session and refreshes the token there.

Check permissions on the server

Route handlers and server actions verify the caller's token rather than trusting the request.

app/api/projects/route.ts
import { nulvlessServer } from '@/lib/nulvless-server';

export async function GET(request: Request) {
  const token = request.headers.get('authorization')?.replace('Bearer ', '');

  const { data, error } = await nulvlessServer.auth.verify(
    { resource: 'proj_16tbwq34', roles: ['owner', 'admin'] },
    { headers: { Authorization: `Bearer ${token}` } },
  );

  if (error) return new Response('Forbidden', { status: 403 });

  return Response.json({ user: data.user.id });
}

Forward the user's headers

Anything the server does on behalf of a user should carry that user's headers, or the session records your infrastructure instead of their device.

import { forwardUserHeaders } from '@nulvless/nulvless-js';

await nulvlessServer.auth.grants.invite(
  { invites: [{ email, role_id: roleId }], resource: orgSlug },
  { headers: forwardUserHeaders(request) },
);

Which client where

Client componentscreateClient

Sign-in, sign-up, session, profile — anything the user does as themselves.

Route handlers, server actionscreateServerClient

Permission checks, grants, admin, comms — anything requiring the secret key.

Next

On this page