Framework guide

Next.js

Set up Neuctra Authix in a Next.js App Router project: one browser client, one server client, session state in a provider, and route protection in middleware.

Next.js 14 & 15App RouterJavaScript & TypeScript

The one thing to get right

Next.js runs your code in two places, and Neuctra Authix has two credentials. They line up exactly: the pk_live_… key belongs in code that reaches the browser, the sk_live_… key belongs in code that never does. Get that mapping right and the rest of this guide is mechanical.

  1. Install the SDK

    Bash
    npm install @neuctra/authix
    
    # or
    yarn add @neuctra/authix
    pnpm add @neuctra/authix
  2. Add your keys

    Next.js only exposes variables prefixed with NEXT_PUBLIC_ to the browser. That prefix is the whole security boundary, so the secret key must not carry it.

    Bash
    # .env.local
    
    # Shipped to the browser — safe to expose.
    NEXT_PUBLIC_AUTHIX_APP_ID=app_xxxxxxxxxxxx
    NEXT_PUBLIC_AUTHIX_PUBLISHABLE_KEY=pk_live_xxxxxxxx_xxxxxxxxxxxxxxxx
    
    # Server only. No NEXT_PUBLIC_ prefix — that prefix is what makes a
    # variable public, and a secret key in the bundle is a full compromise.
    AUTHIX_SECRET_KEY=sk_live_xxxxxxxx_xxxxxxxxxxxxxxxx
  3. Create the browser client

    One shared instance. Creating a client per component means each one keeps its own session, and signing in on one will not sign in the others.

    1// lib/authix.js
    2import { NeuctraAuthix } from "@neuctra/authix";
    3
    4/** Browser-safe client. Acts on behalf of the signed-in user. */
    5export const authix = new NeuctraAuthix({
    6  appId: process.env.NEXT_PUBLIC_AUTHIX_APP_ID,
    7  publishableKey: process.env.NEXT_PUBLIC_AUTHIX_PUBLISHABLE_KEY,
    8  baseUrl: "https://server.authix.neuctra.com/api",
    9});
  4. Create the server client

    Only needed if you call privileged endpoints — listing every user, reading app-wide data, or acting on a user other than the one signed in.

    1// lib/authix.server.js
    2import "server-only";
    3import { NeuctraAuthix } from "@neuctra/authix";
    4
    5/**
    6 * Full account authority. The "server-only" import makes the build fail if a
    7 * client component ever imports this file, which is a much better outcome than
    8 * discovering the key in a bundle later.
    9 */
    10export const authixAdmin = new NeuctraAuthix({
    11  appId: process.env.NEXT_PUBLIC_AUTHIX_APP_ID,
    12  secretKey: process.env.AUTHIX_SECRET_KEY,
    13  baseUrl: "https://server.authix.neuctra.com/api",
    14});

    Install server-only

    npm install server-only. It has no runtime behaviour; importing it makes the build fail if a client component pulls the file in. That failure is the point — it catches a leaked secret key at build time rather than in production.

  5. Hold session state in a provider

    Every component that cares whether someone is signed in reads from one place, and one checkUserSession() call on mount decides it.

    1// app/providers.jsx
    2"use client";
    3
    4import { createContext, useContext, useEffect, useState } from "react";
    5import { authix } from "@/lib/authix";
    6
    7const AuthContext = createContext(null);
    8
    9export function AuthProvider({ children }) {
    10  const [user, setUser] = useState(null);
    11  const [loading, setLoading] = useState(true);
    12
    13  // checkUserSession never throws — an expired or missing cookie simply
    14  // resolves to authenticated: false, so no try/catch is needed here.
    15  const refresh = async () => {
    16    const session = await authix.checkUserSession();
    17    setUser(session.authenticated ? session.user : null);
    18    setLoading(false);
    19    return session;
    20  };
    21
    22  useEffect(() => {
    23    refresh();
    24  }, []);
    25
    26  const value = {
    27    user,
    28    loading,
    29    isSignedIn: Boolean(user),
    30    refresh,
    31    async signIn(email, password) {
    32      await authix.loginUser({ email, password });
    33      return refresh();
    34    },
    35    async signUp(fields) {
    36      await authix.signupUser(fields);
    37      return refresh();
    38    },
    39    async signOut() {
    40      await authix.logoutUser();
    41      setUser(null);
    42    },
    43  };
    44
    45  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
    46}
    47
    48export function useAuth() {
    49  const context = useContext(AuthContext);
    50  if (!context) throw new Error("useAuth must be used inside <AuthProvider>");
    51  return context;
    52}

    Then wrap the app once:

    1// app/layout.jsx
    2import { AuthProvider } from "./providers";
    3
    4export default function RootLayout({ children }) {
    5  return (
    6    <html lang="en">
    7      <body>
    8        <AuthProvider>{children}</AuthProvider>
    9      </body>
    10    </html>
    11  );
    12}
  6. Build the login page

    1// app/login/page.jsx
    2"use client";
    3
    4import { useState } from "react";
    5import { useRouter } from "next/navigation";
    6import { useAuth } from "../providers";
    7
    8export default function LoginPage() {
    9  const { signIn } = useAuth();
    10  const router = useRouter();
    11  const [error, setError] = useState("");
    12  const [busy, setBusy] = useState(false);
    13
    14  async function onSubmit(event) {
    15    event.preventDefault();
    16    setBusy(true);
    17    setError("");
    18
    19    const form = new FormData(event.currentTarget);
    20
    21    try {
    22      await signIn(form.get("email"), form.get("password"));
    23      router.replace("/dashboard");
    24    } catch (err) {
    25      // 401 means the credentials were wrong; anything else is worth
    26      // distinguishing so the user is not told to check a correct password.
    27      setError(
    28        err?.status === 401
    29          ? "Wrong email or password."
    30          : "Something went wrong. Please try again.",
    31      );
    32    } finally {
    33      setBusy(false);
    34    }
    35  }
    36
    37  return (
    38    <form onSubmit={onSubmit}>
    39      <input name="email" type="email" required />
    40      <input name="password" type="password" required />
    41      {error && <p role="alert">{error}</p>}
    42      <button disabled={busy}>{busy ? "Signing in…" : "Sign in"}</button>
    43    </form>
    44  );
    45}

Protecting routes with middleware

Middleware runs before a protected page renders, so an unauthenticated visitor never sees a flash of the dashboard before being redirected.

1// middleware.js
2import { NextResponse } from "next/server";
3
4const PROTECTED = ["/dashboard", "/settings", "/billing"];
5
6export function middleware(request) {
7  const { pathname } = request.nextUrl;
8
9  const needsAuth = PROTECTED.some(
10    (base) => pathname === base || pathname.startsWith(base + "/"),
11  );
12  if (!needsAuth) return NextResponse.next();
13
14  // Presence of the cookie, not validity. Middleware runs on the edge and this
15  // check is only here to avoid a pointless render — the API still verifies the
16  // token on every request, which is where the real protection lives.
17  const hasSession = request.cookies.has("authix_user_session");
18  if (hasSession) return NextResponse.next();
19
20  const login = new URL("/login", request.url);
21  login.searchParams.set("next", pathname);
22  return NextResponse.redirect(login);
23}
24
25export const config = {
26  matcher: ["/dashboard/:path*", "/settings/:path*", "/billing/:path*"],
27};

Middleware is UX, not security

This checks that a cookie exists. It does not verify the token — that needs the signing secret, which does not belong on the edge. A forged cookie gets past middleware and is then rejected by every API call it tries to make.

That is the correct division: middleware saves a wasted render, the server enforces access.

Reading user data

Data calls belong in client components, where the session cookie travels with the request.

1// app/dashboard/notes.jsx
2"use client";
3
4import { useEffect, useState } from "react";
5import { authix } from "@/lib/authix";
6import { useAuth } from "../providers";
7
8export default function Notes() {
9  const { user } = useAuth();
10  const [page, setPage] = useState(null);
11  const [loading, setLoading] = useState(false);
12
13  async function load(cursor) {
14    setLoading(true);
15    const result = await authix.getUserData({
16      userId: user.id,
17      category: "notes",
18      limit: 20,
19      cursor,
20    });
21
22    setPage((current) =>
23      cursor
24        ? { ...result, data: [...current.data, ...result.data] }
25        : result,
26    );
27    setLoading(false);
28  }
29
30  useEffect(() => {
31    if (user) load();
32  }, [user]);
33
34  if (!page) return <p>Loading…</p>;
35
36  return (
37    <>
38      <ul>
39        {page.data.map((note) => (
40          <li key={note.id}>{note.title}</li>
41        ))}
42      </ul>
43
44      {page.hasMore && (
45        <button onClick={() => load(page.nextCursor)} disabled={loading}>
46          {loading ? "Loading…" : "Load more"}
47        </button>
48      )}
49    </>
50  );
51}

Why not fetch this in a Server Component?

You can, but only with the secret client — a Server Component has no access to the browser's cookie jar, so the publishable client there is unauthenticated. Using the secret key to fetch one user's notes also means you become responsible for checking that the request belongs to that user. Reading from the client keeps the server's ownership guarantee doing that work.

Privileged work in Server Actions

Anything that reads across users needs the secret key, which means it must run on the server.

1// app/admin/actions.js
2"use server";
3
4import { authixAdmin } from "@/lib/authix.server";
5
6/**
7 * Runs on the server, so it may use the secret key and read across users.
8 * Authorise the caller yourself before returning anything — a Server Action is
9 * a public endpoint, not a private function.
10 */
11export async function listUsers(cursor) {
12  return authixAdmin.getAllUsersFromApp({ limit: 50, cursor });
13}

Authorise the caller

A Server Action is reachable by anyone who can reach your site. The secret key gives it full account authority, so check that the caller is allowed to run it before returning anything — being logged in is not the same as being an admin.

Project structure

Where each piece ends up in a typical App Router project.

Bash
your-app/
├── app/
│   ├── layout.jsx           # wraps everything in <AuthProvider>
│   ├── providers.jsx        # "use client" — session state + useAuth()
│   ├── login/page.jsx       # "use client" — sign-in form
│   ├── signup/page.jsx      # "use client" — sign-up + verification
│   ├── dashboard/
│   │   ├── page.jsx         # protected by middleware
│   │   └── notes.jsx        # "use client" — reads user records
│   └── admin/
│       └── actions.js       # "use server" — secret key only
├── lib/
│   ├── authix.js            # publishable client (browser)
│   └── authix.server.js     # secret client ("server-only")
├── middleware.js            # redirects unauthenticated users
└── .env.local

Common mistakes

  • Prefixing the secret key with NEXT_PUBLIC_. It will be inlined into the JavaScript bundle and readable by anyone. Rotate the key immediately if this has happened.
  • Creating a client inside a component. Each instance keeps its own session; put one in lib/authix.js and import it.
  • Calling data methods from a Server Component with the publishable client. There is no cookie there, so the call is unauthenticated and returns NO_USER_SESSION.
  • Treating middleware as the security boundary. It cannot verify a token. The API does that on every request.
  • Skipping email verification. An unverified account can sign in but cannot store records — the write fails with a 403 later, far from the cause.

You now have

  • Sign-up, sign-in and sign-out backed by a real session.
  • Protected routes that redirect before rendering.
  • Per-user data reads that cannot reach another user's rows.
  • A server client for privileged work, kept out of the bundle.

Related