Framework guide
React
Set up Neuctra Authix in a Vite or Create React App project: one client, an auth context, protected routes, and a hook for paged user data.
Everything here ships to the browser
A React SPA has no server of its own, so only the publishable key pk_live_… may be used. Never put a sk_live_… key in a VITE_ or REACT_APP_ variable — both are compiled into the bundle in plain text.
If you need to list every user or read app-wide data, those calls require a secret key and belong on a server. See the Node & Express guide.
Install
Bashnpm install @neuctra/authix react-router-dom # or yarn add @neuctra/authix react-router-dom pnpm add @neuctra/authix react-router-domAdd your keys
Bash# .env (Vite) VITE_AUTHIX_APP_ID=app_xxxxxxxxxxxx VITE_AUTHIX_PUBLISHABLE_KEY=pk_live_xxxxxxxx_xxxxxxxxxxxxxxxx # .env (Create React App) REACT_APP_AUTHIX_APP_ID=app_xxxxxxxxxxxx REACT_APP_AUTHIX_PUBLISHABLE_KEY=pk_live_xxxxxxxx_xxxxxxxxxxxxxxxxCreate the client
One instance for the whole app. Each client keeps its own session, so creating them per component means signing in on one leaves the others signed out.
1// src/lib/authix.js 2import { NeuctraAuthix } from "@neuctra/authix"; 3 4export const authix = new NeuctraAuthix({ 5 appId: import.meta.env.VITE_AUTHIX_APP_ID, 6 publishableKey: import.meta.env.VITE_AUTHIX_PUBLISHABLE_KEY, 7 baseUrl: "https://server.authix.neuctra.com/api", 8});Hold the session in context
1// src/context/AuthContext.jsx 2import { createContext, useContext, useEffect, useState } from "react"; 3import { authix } from "../lib/authix"; 4 5const AuthContext = createContext(null); 6 7export function AuthProvider({ children }) { 8 const [user, setUser] = useState(null); 9 10 // "loading" starts true so protected routes can wait rather than bouncing a 11 // signed-in user to the login screen during the first check. 12 const [loading, setLoading] = useState(true); 13 14 async function refresh() { 15 const session = await authix.checkUserSession(); 16 setUser(session.authenticated ? session.user : null); 17 setLoading(false); 18 return session; 19 } 20 21 useEffect(() => { 22 refresh(); 23 }, []); 24 25 const value = { 26 user, 27 loading, 28 isSignedIn: Boolean(user), 29 refresh, 30 signIn: async (email, password) => { 31 await authix.loginUser({ email, password }); 32 return refresh(); 33 }, 34 signUp: async (fields) => { 35 await authix.signupUser(fields); 36 return refresh(); 37 }, 38 signOut: async () => { 39 await authix.logoutUser(); 40 setUser(null); 41 }, 42 }; 43 44 return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; 45} 46 47export function useAuth() { 48 const context = useContext(AuthContext); 49 if (!context) throw new Error("useAuth must be used inside <AuthProvider>"); 50 return context; 51}Protect your routes
1// src/App.jsx 2import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; 3import { AuthProvider, useAuth } from "./context/AuthContext"; 4import Login from "./pages/Login"; 5import Signup from "./pages/Signup"; 6import Dashboard from "./pages/Dashboard"; 7 8/** 9 * Waiting on "loading" is what stops a refresh on /dashboard from flashing the 10 * login page before the session check comes back. 11 */ 12function RequireAuth({ children }) { 13 const { isSignedIn, loading } = useAuth(); 14 15 if (loading) return <p>Loading…</p>; 16 if (!isSignedIn) return <Navigate to="/login" replace />; 17 18 return children; 19} 20 21function RedirectIfSignedIn({ children }) { 22 const { isSignedIn, loading } = useAuth(); 23 24 if (loading) return <p>Loading…</p>; 25 if (isSignedIn) return <Navigate to="/dashboard" replace />; 26 27 return children; 28} 29 30export default function App() { 31 return ( 32 <AuthProvider> 33 <BrowserRouter> 34 <Routes> 35 <Route 36 path="/login" 37 element={ 38 <RedirectIfSignedIn> 39 <Login /> 40 </RedirectIfSignedIn> 41 } 42 /> 43 <Route 44 path="/signup" 45 element={ 46 <RedirectIfSignedIn> 47 <Signup /> 48 </RedirectIfSignedIn> 49 } 50 /> 51 <Route 52 path="/dashboard" 53 element={ 54 <RequireAuth> 55 <Dashboard /> 56 </RequireAuth> 57 } 58 /> 59 <Route path="*" element={<Navigate to="/dashboard" replace />} /> 60 </Routes> 61 </BrowserRouter> 62 </AuthProvider> 63 ); 64}Sign users up and verify them
1// src/pages/Signup.jsx 2import { useState } from "react"; 3import { authix } from "../lib/authix"; 4import { useAuth } from "../context/AuthContext"; 5 6export default function Signup() { 7 const { signUp } = useAuth(); 8 const [stage, setStage] = useState("form"); 9 const [email, setEmail] = useState(""); 10 const [error, setError] = useState(""); 11 12 async function onSubmit(event) { 13 event.preventDefault(); 14 setError(""); 15 16 const form = new FormData(event.currentTarget); 17 const address = String(form.get("email")); 18 19 try { 20 const session = await signUp({ 21 name: String(form.get("name")), 22 email: address, 23 password: String(form.get("password")), 24 }); 25 26 // Signup signs the user in, but the account is not verified yet — and an 27 // unverified account cannot store records. Send the code straight away. 28 await authix.requestEmailVerificationOTP({ 29 userId: session.user.id, 30 email: address, 31 }); 32 33 setEmail(address); 34 setStage("verify"); 35 } catch (err) { 36 setError(err?.message || "Could not create the account."); 37 } 38 } 39 40 async function onVerify(event) { 41 event.preventDefault(); 42 const form = new FormData(event.currentTarget); 43 44 await authix.verifyEmail({ 45 email, 46 otp: String(form.get("otp")), 47 }); 48 49 window.location.replace("/dashboard"); 50 } 51 52 if (stage === "verify") { 53 return ( 54 <form onSubmit={onVerify}> 55 <p>We sent a code to {email}.</p> 56 <input name="otp" inputMode="numeric" required /> 57 <button>Verify</button> 58 </form> 59 ); 60 } 61 62 return ( 63 <form onSubmit={onSubmit}> 64 <input name="name" required /> 65 <input name="email" type="email" required /> 66 <input name="password" type="password" required /> 67 {error && <p role="alert">{error}</p>} 68 <button>Create account</button> 69 </form> 70 ); 71}
A hook for paged data
Reads are bounded — 20 records by default, 100 maximum — so a list UI needs a cursor and a “load more”, not a single fetch.
1// src/hooks/useUserData.js
2import { useCallback, useEffect, useState } from "react";
3import { authix } from "../lib/authix";
4import { useAuth } from "../context/AuthContext";
5
6/**
7 * Paged reads for one category, with a "load more" that appends rather than
8 * replacing — which is what a list UI actually needs.
9 */
10export function useUserData(category) {
11 const { user } = useAuth();
12 const [items, setItems] = useState([]);
13 const [cursor, setCursor] = useState(null);
14 const [hasMore, setHasMore] = useState(false);
15 const [loading, setLoading] = useState(false);
16
17 const load = useCallback(
18 async (nextCursor) => {
19 if (!user) return;
20 setLoading(true);
21
22 const page = await authix.getUserData({
23 userId: user.id,
24 category,
25 limit: 20,
26 cursor: nextCursor,
27 });
28
29 setItems((current) =>
30 nextCursor ? [...current, ...page.data] : page.data,
31 );
32 setCursor(page.nextCursor ?? null);
33 setHasMore(Boolean(page.hasMore));
34 setLoading(false);
35 },
36 [user, category],
37 );
38
39 useEffect(() => {
40 load();
41 }, [load]);
42
43 return {
44 items,
45 hasMore,
46 loading,
47 loadMore: () => load(cursor),
48 reload: () => load(),
49 };
50}Then a page is mostly markup:
1// src/pages/Dashboard.jsx
2import { useUserData } from "../hooks/useUserData";
3import { useAuth } from "../context/AuthContext";
4
5export default function Dashboard() {
6 const { user, signOut } = useAuth();
7 const { items, hasMore, loading, loadMore } = useUserData("notes");
8
9 return (
10 <>
11 <header>
12 <span>{user.email}</span>
13 <button onClick={signOut}>Sign out</button>
14 </header>
15
16 <ul>
17 {items.map((note) => (
18 <li key={note.id}>{note.title}</li>
19 ))}
20 </ul>
21
22 {hasMore && (
23 <button onClick={loadMore} disabled={loading}>
24 {loading ? "Loading…" : "Load more"}
25 </button>
26 )}
27 </>
28 );
29}Why RequireAuth is not security
It decides what renders. It does not decide what the API returns.
The server owns access
Someone can edit your bundle and render Dashboard without signing in. Every call it makes will still fail, because requests carrying a publishable key are pinned to the signed-in session on the server — the user id in the request is ignored.
So treat route guards as user experience, and never as the thing keeping one user out of another user's data.
Project structure
Where each piece lands in a typical Vite project.
src/
├── lib/
│ └── authix.js # one shared client
├── context/
│ └── AuthContext.jsx # session state + useAuth()
├── hooks/
│ └── useUserData.js # paged reads for one category
├── pages/
│ ├── Login.jsx
│ ├── Signup.jsx # signup + email verification
│ └── Dashboard.jsx # protected
└── App.jsx # routes + RequireAuthCommon mistakes
- Not waiting for the first session check. Without the
loadinggate, refreshing a protected page bounces a signed-in user to the login screen. - Creating a client per component. Sessions stop being shared. Import the one from
lib/authix. - Skipping email verification. The account can sign in but every write fails with a 403 until it is verified.
- Fetching everything at once. There is no unbounded read; follow
nextCursorinstead. - Putting a secret key in a build variable. It ends up in the bundle. Rotate it immediately if this has happened.
You now have
- Signup with email verification, login and logout.
- Routes that wait for the session before deciding.
- A reusable hook for paged, per-user data.
Related