| ## SanadCheck LLM API β Next.js Integration (with Authentication) | |
| This guide shows how to securely integrate the SanadCheck API into a Next.js 13/14+ (App Router) project, with proper JWT handling (access + refresh), protected server actions, and client usage examples. | |
| --- | |
| ## 1. High-Level Architecture | |
| Recommended pattern: keep ALL direct calls to the FastAPI service on the server (Next.js Route Handlers / Server Actions) so refresh tokens never reach the browser's JS runtime. | |
| Flow: | |
| 1. User submits credentials (email/password) via a client component form. | |
| 2. Form calls a Next.js Route Handler: `POST /api/auth/login`. | |
| 3. Route handler forwards to FastAPI `/auth/login`. | |
| 4. Response contains: `access_token`, `refresh_token`, `expires_in`, `user`. | |
| 5. Next handler sets: | |
| - `sc_refresh` (HttpOnly, Secure, SameSite=Strict) β refresh token | |
| - `sc_access` (HttpOnly OR short-lived in-memory re-fetched via server action) β access token | |
| - Optionally store decoded `user` object in a signed cookie or re-fetch via `/auth/me` per request. | |
| 6. Client components fetch data by calling internal Next.js API routes (proxy) that attach `Authorization: Bearer <access_token>`. | |
| 7. If access token expired, the proxy handler uses the refresh token cookie to get a new pair (rotate!) transparently. | |
| Why this pattern: | |
| - Avoids XSS exposure of refresh tokens | |
| - Centralizes refresh logic | |
| - Enables SSR + Server Actions with authenticated context | |
| --- | |
| ## 2. Backend Endpoints Used | |
| Auth: | |
| - `POST /auth/register` | |
| - `POST /auth/login` | |
| - `POST /auth/refresh` | |
| - `POST /auth/logout` | |
| - `GET /auth/me` | |
| - `GET /auth/sessions` | |
| Core Hadith Analysis (all protected except `/api/v1/health` + some analytics): | |
| - `POST /api/v1/extract-narrators` | |
| - `POST /api/v1/analyze-narrator` | |
| - `POST /api/v1/analyze-narrator-chain` | |
| - `POST /api/v1/extract-and-analyze` | |
| - `GET /api/v1/user/extractions` | |
| - `GET /api/v1/user/analyses` | |
| - `GET /api/v1/analytics/stats` (public) | |
| - `GET /api/v1/analytics/popular-narrators` (public) | |
| - `GET /api/v1/health` (public) | |
| --- | |
| ## 3. Environment Variables (Next.js) | |
| In `.env.local`: | |
| ``` | |
| SANAD_API_BASE_URL=http://localhost:8000 # FastAPI base | |
| NEXT_PUBLIC_SANAD_PUBLIC_BASE=/api/sanad # Public-facing proxy base (optional) | |
| ``` | |
| Never expose service keys. Only the public base URL may be exposed. | |
| --- | |
| ## 4. Directory Structure (Suggested) | |
| ``` | |
| app/ | |
| api/ | |
| auth/ | |
| login/route.ts | |
| register/route.ts | |
| logout/route.ts | |
| refresh/route.ts # (Optional explicit refresh) | |
| me/route.ts | |
| sanad/ | |
| extract-narrators/route.ts | |
| analyze-narrator/route.ts | |
| extract-and-analyze/route.ts | |
| user/ | |
| extractions/route.ts | |
| analyses/route.ts | |
| analytics/ | |
| stats/route.ts | |
| popular-narrators/route.ts | |
| (UI pages & components...) | |
| lib/ | |
| apiClient.ts | |
| auth.ts | |
| tokens.ts | |
| middleware.ts (optional token freshness logic) | |
| components/ | |
| AuthProvider.tsx | |
| LoginForm.tsx | |
| ``` | |
| --- | |
| ## 5. TypeScript Models | |
| Create `lib/types.ts`: | |
| ```ts | |
| export interface User { | |
| id: string; | |
| email: string; | |
| username: string; | |
| full_name: string; | |
| role: string; | |
| is_active: boolean; | |
| } | |
| export interface AuthResponse { | |
| access_token: string; | |
| refresh_token: string; | |
| token_type: 'bearer'; | |
| expires_in: number; // seconds | |
| user: User; | |
| } | |
| export interface NarratorExtractionResponse { | |
| narrators: string[]; | |
| sanad_chain: string; | |
| success: boolean; | |
| message?: string; | |
| } | |
| export interface NarratorAnalysisResponse { | |
| narrator_name: string; | |
| reliability_grade: string; | |
| confidence_level: string; | |
| reasoning: string; | |
| scholarly_consensus: string; | |
| known_issues?: string[] | null; | |
| biographical_info: string; | |
| recommendation: string; | |
| success: boolean; | |
| message?: string; | |
| } | |
| ``` | |
| --- | |
| ## 6. Secure Token Handling Strategy | |
| | Aspect | Recommendation | | |
| |--------|---------------| | |
| | Access Token | Short-lived (minutes). Store in httpOnly cookie `sc_access` or re-fetch via server action each request. | | |
| | Refresh Token | HttpOnly + Secure cookie `sc_refresh` only. Never expose to JS. | | |
| | Rotation | Always replace both tokens on refresh (server enforces blacklisting). | | |
| | Expiry Tracking | Store `exp` in cookie or decode server-side. Trigger refresh when <60s left. | | |
| | Logout | Clear both cookies + call backend `/auth/logout` with current access token. | | |
| Cookie examples (set in route handlers): | |
| ```ts | |
| cookies().set('sc_refresh', refreshToken, { | |
| httpOnly: true, | |
| secure: process.env.NODE_ENV === 'production', | |
| sameSite: 'strict', | |
| path: '/', | |
| maxAge: 60 * 60 * 24 * 7, // adjust to backend refresh expiry | |
| }); | |
| cookies().set('sc_access', accessToken, { | |
| httpOnly: true, | |
| secure: process.env.NODE_ENV === 'production', | |
| sameSite: 'strict', | |
| path: '/', | |
| maxAge: 60 * 15, | |
| }); | |
| ``` | |
| --- | |
| ## 7. Generic Fetch Wrapper (Server) | |
| `lib/apiClient.ts`: | |
| ```ts | |
| import { cookies } from 'next/headers'; | |
| const BASE = process.env.SANAD_API_BASE_URL!; | |
| async function refreshIfNeeded(): Promise<string | null> { | |
| const store = cookies(); | |
| const access = store.get('sc_access')?.value; | |
| if (access) return access; // Or decode & check exp | |
| const refresh = store.get('sc_refresh')?.value; | |
| if (!refresh) return null; | |
| // Call internal refresh route (not FastAPI directly) | |
| const res = await fetch(`${process.env.NEXT_PUBLIC_SANAD_PUBLIC_BASE || ''}/auth/refresh`, { method: 'POST' }); | |
| if (!res.ok) return null; | |
| const data = await res.json(); | |
| return cookies().get('sc_access')?.value || null; // After handler sets new cookie | |
| } | |
| export async function sanadFetch<T>(path: string, init: RequestInit = {}): Promise<T> { | |
| const token = await refreshIfNeeded(); | |
| const headers = new Headers(init.headers || {}); | |
| if (token) headers.set('Authorization', `Bearer ${token}`); | |
| headers.set('Content-Type', 'application/json'); | |
| const res = await fetch(`${BASE}${path}`, { ...init, headers, cache: 'no-store' }); | |
| if (!res.ok) { | |
| const body = await res.text(); | |
| throw new Error(`Sanad API error ${res.status}: ${body}`); | |
| } | |
| return res.json() as Promise<T>; | |
| } | |
| ``` | |
| --- | |
| ## 8. Route Handler Examples (Proxy Pattern) | |
| `app/api/auth/login/route.ts`: | |
| ```ts | |
| import { NextRequest, NextResponse } from 'next/server'; | |
| import { cookies } from 'next/headers'; | |
| export async function POST(req: NextRequest) { | |
| const creds = await req.json(); | |
| const res = await fetch(`${process.env.SANAD_API_BASE_URL}/auth/login`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(creds), | |
| }); | |
| if (!res.ok) { | |
| return NextResponse.json({ error: 'Login failed' }, { status: res.status }); | |
| } | |
| const data = await res.json(); | |
| const c = cookies(); | |
| c.set('sc_refresh', data.refresh_token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: data.expires_in * 4 }); | |
| c.set('sc_access', data.access_token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: data.expires_in }); | |
| return NextResponse.json({ user: data.user }); | |
| } | |
| ``` | |
| `app/api/auth/refresh/route.ts`: | |
| ```ts | |
| import { cookies } from 'next/headers'; | |
| import { NextResponse } from 'next/server'; | |
| export async function POST() { | |
| const refresh = cookies().get('sc_refresh')?.value; | |
| if (!refresh) return NextResponse.json({ error: 'No refresh token' }, { status: 401 }); | |
| const res = await fetch(`${process.env.SANAD_API_BASE_URL}/auth/refresh`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ refresh_token: refresh }), | |
| }); | |
| if (!res.ok) return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); | |
| const data = await res.json(); | |
| const c = cookies(); | |
| c.set('sc_refresh', data.refresh_token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: data.expires_in * 4 }); | |
| c.set('sc_access', data.access_token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: data.expires_in }); | |
| return NextResponse.json({ status: 'refreshed' }); | |
| } | |
| ``` | |
| `app/api/sanad/extract-narrators/route.ts`: | |
| ```ts | |
| import { NextRequest, NextResponse } from 'next/server'; | |
| import { sanadFetch } from '@/lib/apiClient'; | |
| export async function POST(req: NextRequest) { | |
| const body = await req.json(); | |
| try { | |
| const data = await sanadFetch('/api/v1/extract-narrators', { method: 'POST', body: JSON.stringify(body) }); | |
| return NextResponse.json(data); | |
| } catch (e: any) { | |
| return NextResponse.json({ error: e.message }, { status: 500 }); | |
| } | |
| } | |
| ``` | |
| Add similar handlers for `analyze-narrator`, `extract-and-analyze`, etc. | |
| --- | |
| ## 9. Client Hook (Optional Thin Layer) | |
| `lib/useSanad.ts` (Client Component safe): | |
| ```ts | |
| import { useState } from 'react'; | |
| export function useSanad() { | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| async function post<T>(path: string, payload: any): Promise<T | null> { | |
| setLoading(true); setError(null); | |
| try { | |
| const res = await fetch(`/api/sanad${path}`, { method: 'POST', body: JSON.stringify(payload) }); | |
| const json = await res.json(); | |
| if (!res.ok) throw new Error(json.error || 'Request failed'); | |
| return json as T; | |
| } catch (e: any) { setError(e.message); return null; } | |
| finally { setLoading(false); } | |
| } | |
| return { post, loading, error }; | |
| } | |
| ``` | |
| Usage in a component: | |
| ```tsx | |
| const { post, loading, error } = useSanad(); | |
| async function handleExtract(text: string) { | |
| const data = await post('/extract-narrators', { hadith_text: text }); | |
| console.log(data); | |
| } | |
| ``` | |
| --- | |
| ## 10. Server Action Example (Optional) | |
| If you prefer server actions instead of route handlers for some flows: | |
| ```ts | |
| 'use server'; | |
| import { sanadFetch } from '@/lib/apiClient'; | |
| export async function extractNarratorsAction(hadith: string) { | |
| return sanadFetch('/api/v1/extract-narrators', { method: 'POST', body: JSON.stringify({ hadith_text: hadith }) }); | |
| } | |
| ``` | |
| Call inside a Server Component or via form action. | |
| --- | |
| ## 11. Login Form (Client) Example | |
| ```tsx | |
| 'use client'; | |
| import { useState } from 'react'; | |
| export function LoginForm() { | |
| const [email, setEmail] = useState(''); | |
| const [password, setPassword] = useState(''); | |
| const [error, setError] = useState<string | null>(null); | |
| async function submit(e: React.FormEvent) { | |
| e.preventDefault(); setError(null); | |
| const res = await fetch('/api/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }); | |
| const data = await res.json(); | |
| if (!res.ok) setError(data.error || 'Login failed'); | |
| // success: user available in data.user, tokens stored as cookies | |
| } | |
| return ( | |
| <form onSubmit={submit} className="space-y-2"> | |
| <input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" /> | |
| <input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" /> | |
| <button type="submit">Login</button> | |
| {error && <p className="text-red-500">{error}</p>} | |
| </form> | |
| ); | |
| } | |
| ``` | |
| --- | |
| ## 12. Handling Errors | |
| Pattern: | |
| ```ts | |
| try { | |
| const res = await sanadFetch('/api/v1/analyze-narrator', { method: 'POST', body: JSON.stringify({ narrator_name }) }); | |
| } catch (e: any) { | |
| // Handle errors appropriately | |
| console.error('API Error:', e.message); | |
| } | |
| ``` | |
| --- | |
| ## 13. Logout Flow | |
| Route handler `app/api/auth/logout/route.ts`: | |
| ```ts | |
| import { cookies } from 'next/headers'; | |
| import { NextResponse } from 'next/server'; | |
| export async function POST() { | |
| const access = cookies().get('sc_access')?.value; | |
| if (access) { | |
| await fetch(`${process.env.SANAD_API_BASE_URL}/auth/logout`, { | |
| method: 'POST', | |
| headers: { Authorization: `Bearer ${access}` } | |
| }).catch(() => {}); | |
| } | |
| const c = cookies(); | |
| c.delete('sc_access'); | |
| c.delete('sc_refresh'); | |
| return NextResponse.json({ status: 'logged_out' }); | |
| } | |
| ``` | |
| Client call: | |
| ```ts | |
| await fetch('/api/auth/logout', { method: 'POST' }); | |
| ``` | |
| --- | |
| ## 14. Middleware (Optional Early Refresh) | |
| `middleware.ts` (basic sketch β only if you want silent refresh before protected routes): | |
| ```ts | |
| import { NextRequest, NextResponse } from 'next/server'; | |
| export async function middleware(req: NextRequest) { | |
| const access = req.cookies.get('sc_access'); | |
| if (!access) { | |
| const refresh = req.cookies.get('sc_refresh'); | |
| if (refresh) { | |
| await fetch(new URL('/api/auth/refresh', req.url), { method: 'POST' }); | |
| } | |
| } | |
| return NextResponse.next(); | |
| } | |
| export const config = { matcher: ['/dashboard/:path*', '/analysis/:path*'] }; | |
| ``` | |
| --- | |
| ## 15. Security Checklist | |
| - Use `Secure` cookies in production (HTTPS) | |
| - Set `SameSite=Strict` to mitigate CSRF (or use custom CSRF token if cross-site embedding needed) | |
| - Do NOT store refresh tokens in `localStorage` or JS-accessible cookies | |
| - Rotate tokens on every refresh | |
| - Handle logout on 401 loops (e.g., if refresh also fails) | |
| --- | |
| ## 16. Minimal End-to-End Example | |
| 1. User visits `/login` β submits form | |
| 2. `/api/auth/login` sets cookies | |
| 3. User navigates to `/dashboard` (protected) | |
| 4. Server component calls server action or `fetch('/api/sanad/extract-narrators')` | |
| 5. Handler attaches Authorization header β FastAPI processes β returns JSON | |
| 6. Access token expires β next call triggers `/api/auth/refresh` implicitly β cookies updated | |
| 7. User clicks logout β `/api/auth/logout` β cookies cleared + token blacklisted | |
| --- | |
| ## 17. Troubleshooting | |
| | Symptom | Cause | Fix | | |
| |---------|-------|-----| | |
| | 401 on every request | Missing Authorization header | Ensure proxy sets header after refresh | | |
| | 401 after refresh | Refresh token expired/blacklisted | Force logout & re-login | | |
| | CORS errors (if bypassing proxy) | Direct browser β FastAPI without proper CORS | Always use Next.js proxy or configure CORS on backend | | |
| | Cookie not set in prod | Missing `secure` or domain mismatch | Set correct domain & HTTPS | | |
| --- | |
| ## 18. Next Steps / Enhancements | |
| - Add SWR/React Query for caching | |
| - Add optimistic UI for chain analysis | |
| - Add user session list page using `/auth/sessions` | |
| - Implement progress indicators for long analyses | |
| --- | |
| ## 19. Quick Reference (Cheat Sheet) | |
| Auth Cycle: | |
| ``` | |
| POST /api/auth/login -> sets cookies | |
| POST /api/auth/refresh -> rotates tokens | |
| POST /api/auth/logout -> clears + blacklists | |
| GET /api/auth/me -> user profile | |
| ``` | |
| Core Calls: | |
| ``` | |
| POST /api/sanad/extract-narrators | |
| POST /api/sanad/analyze-narrator | |
| POST /api/sanad/extract-and-analyze | |
| GET /api/sanad/user/extractions | |
| GET /api/sanad/user/analyses | |
| ``` | |
| --- | |
| ## 20. Summary | |
| Use a proxy pattern + HttpOnly cookies to keep tokens safe, centralize refresh logic, and provide a clean developer experience in your Next.js app. The snippets above can be copied directly and adjusted to your folder naming. Expand with caching & UI state management as needed. | |
| If you need a tailored example repository scaffold, ask and we can generate it. | |