Skip to content

Integration Guide

Document Type: Frontend-Backend Communication Generated: 2026-02-12 | Last Verified: 2026-03-26 | Scan Level: Exhaustive | Workflow: document-project v1.2.0

Overview

The frontend (Next.js 15) communicates with the backend (Express.js) via REST API calls. Client-side components use Axios with interceptors; server-side components (RSC) use native fetch() with Next.js cache tags. Authentication is handled through JWT tokens stored in a JS-accessible cookie (not httpOnly).

Communication Flow

┌──────────────────────┐                      ┌──────────────────────┐
│       Frontend       │                      │       Backend        │
│    (Next.js 15)      │                      │    (Express.js)      │
├──────────────────────┤                      ├──────────────────────┤
│                      │                      │                      │
│  Client Components   │   Axios + Bearer     │                      │
│  (139 RQ hooks,      │─────────────────────>│   Express Routes     │
│   31 hook files)     │                      │                      │
│                      │<─────────────────────│   JSON Response      │
│                      │                      │                      │
│  Server Components   │   fetch() + tags     │                      │
│  (RSC)               │─────────────────────>│   Public endpoints   │
│                      │<─────────────────────│                      │
│                      │                      │                      │
│  Cookie: "token"     │   (not httpOnly)     │   JWT in response    │
│  (JS-readable)       │<─────────────────────│   body, not header   │
└──────────────────────┘                      └──────────────────────┘

Environment Configuration

Frontend (.env.local)

# Public API URL (used in browser / client components)
NEXT_PUBLIC_API_URL=http://localhost:5500/api/v1

# Server-side API URL (used in server components / RSC)
API_URL=http://localhost:5500/api/v1

# Stripe (client-side)
STRIPE_PUBLISHABLE_KEY=pk_test_...

Backend (.env)

# Client URL for CORS and redirects
CLIENT_URL=http://localhost:3000

# CORS origins (JSON array)
ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:3001"]

# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Email (one of: nodemailer, resend, brevo)
EMAIL_PROVIDER=nodemailer

# Storage (one of: cloudinary, aws, local)
STORAGE_PROVIDER=cloudinary

Authentication Integration

1. Login Flow

Frontend                              Backend
   │                                     │
   │  POST /auth/login                   │
   │  {email, password, rememberMe}      │
   ├────────────────────────────────────>│
   │                                     │ Validate credentials
   │                                     │ Check if 2FA enabled
   │                                     │
   │  ┌─ If 2FA required ──────────────┐│
   │  │ Return early: {twoFactor: true} ││
   │  │ NO cookie set, NO token        ││
   │  │ Frontend redirects to 2FA page ││
   │  └────────────────────────────────┘│
   │                                     │
   │  ┌─ Normal login ────────────────┐ │
   │  │ {token, user}                 │ │
   │  │<────────────────────────────────┤
   │  │                                 │
   │  │ setCookie("token", value, {     │
   │  │   maxAge: 30 days (rememberMe)  │
   │  │         or 7 days (default),    │
   │  │   sameSite: "strict",           │
   │  │   secure: NODE_ENV==="production",│
   │  │   NOT httpOnly                  │
   │  │ })                              │
   │  │                                 │
   │  │ dispatch(setAuth({token}))      │
   │  │ — sets isAuthenticated + token  │
   │  │ — does NOT set user in Redux    │
   │  │                                 │
   │  │ window.location.href="/dashboard"│
   │  │ (hard redirect, full page       │
   │  │  reload — not Next.js router)   │
   │  └─────────────────────────────────┘
   │                                     │
// On login success (login page)
import { setCookie } from "cookies-next";

const handleLogin = async (credentials) => {
  const response = await authAPI.login(credentials);

  // 2FA early return — no cookie, no Redux update
  if (response.twoFactor) {
    // redirect to 2FA verification page
    return;
  }

  // Store token in cookie (JS-accessible, NOT httpOnly)
  setCookie("token", response.token, {
    maxAge: credentials.rememberMe
      ? 60 * 60 * 24 * 30   // 30 days
      : 60 * 60 * 24 * 7,   // 7 days
    sameSite: "strict",
    secure: process.env.NODE_ENV === "production",
    path: "/",
  });

  // Update Redux — sets isAuthenticated + token, but NOT user
  dispatch(setAuth({ token: response.token }));

  // Hard redirect (full page reload, not Next.js router.push)
  window.location.href = "/dashboard";
};

3. Auth Initializer (App Load)

// components/providers/auth-initializer.jsx
"use client";

export default function AuthInitializer({ children }) {
  const dispatch = useDispatch();

  useEffect(() => {
    // initializeAuth() reads the "token" cookie
    // Sets isAuthenticated + token in Redux, but NOT user
    // User data is fetched separately by useProfile()
    dispatch(initializeAuth());

    // Wire Redux dispatch to Axios interceptor for logout-on-401
    setStoreDispatch(dispatch);
  }, [dispatch]);

  return children;
}

Key detail: initializeAuth() only restores the token and isAuthenticated flag. The actual user object (with role, permissions, etc.) is loaded separately by useProfile(), which calls GET /users/profile. This means there is a brief window after page load where isAuthenticated is true but user is null.

4. Axios Interceptor (Request)

// interceptors/axiosInstance.js
const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  withCredentials: true,  // Send cookies with requests
});

api.interceptors.request.use((config) => {
  const token = getCookie("token");
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

5. Axios Interceptor (Response Error)

api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401 || error.response?.status === 403) {
      // Clear cookie
      deleteCookie("token");

      // Clear Redux state
      if (storeDispatch) {
        storeDispatch(logout());
      }

      // Clear React Query cache
      // (queryClient.clear() called in logout action)

      // Redirect to login
      if (typeof window !== "undefined") {
        window.location.href = "/login";
      }
    }
    return Promise.reject(error);
  }
);

Known Issue: 403 (Forbidden) triggers a full logout identical to 401 (Unauthorized). A 403 means the user is authenticated but lacks permission for that resource — it should NOT destroy the session. This causes users to be logged out when they simply lack a specific permission.

6. Logout Flow

1. deleteCookie("token")         — remove JWT cookie
2. dispatch(logout())            — clear Redux auth state
3. queryClient.clear()           — wipe all React Query cache
4. redirect to /login            — via window.location.href

Middleware Protection

Next.js Middleware

// middleware.js
import { NextResponse } from "next/server";

export async function middleware(req) {
  const token = req.cookies.get("token")?.value;
  const { pathname } = req.nextUrl;

  // Protect dashboard routes
  if (pathname.startsWith("/dashboard")) {
    if (!token) {
      return NextResponse.redirect(new URL("/login", req.url));
    }
  }

  // Redirect authenticated users away from auth pages
  if (token && ["/login", "/register"].includes(pathname)) {
    return NextResponse.redirect(new URL("/dashboard", req.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/login", "/register", "/dashboard/:path*"],
};

Note: Middleware only checks for cookie existence — it does not validate the JWT. An expired or malformed token will pass middleware but fail on the first API call, triggering the Axios 401 interceptor.

Data Fetching Patterns

1. React Query Hooks (Client Components)

Scale: 139 React Query hooks across 31 files.

// api/users/index.js
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/interceptors/axiosInstance";

// Fetch current user profile
export const useProfile = () => {
  return useQuery({
    queryKey: ["user", "profile"],
    queryFn: async () => {
      const response = await api.get("/users/profile");
      return response.data.data;
    },
    staleTime: 5 * 60 * 1000,  // 5 minutes
  });
};

// Fetch users list
export const useUsers = (params) => {
  return useQuery({
    queryKey: ["users", params],
    queryFn: async () => {
      const response = await api.get("/users", { params });
      return response.data;
    },
    // staleTime: CACHE_TIME (configurable, in hours)
  });
};

// Create user mutation
export const useCreateUser = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data) => api.post("/users", data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });
};

React Query Provider Configuration: - No retry on 401 or 403 status codes - staleTime varies: 5 minutes for profile, CACHE_TIME hours for list queries - Provider wraps the app in the root layout

2. Server-Side Fetching (Server Components / RSC)

Server components use native fetch() with Next.js cache tags for on-demand revalidation.

7 cache tags in use: | Tag | Used For | |---|---| | blogs | Blog listing pages | | blog-details | Individual blog post pages | | dynamic-page | CMS dynamic pages | | navigation-links | Header/footer/sidebar menus | | packages-data | Pricing/subscription packages | | settings-group | Site settings by group/prefix | | all-settings | All settings (global fetch) |

// Server component fetching with cache tags
async function getBlogs() {
  const response = await fetch(`${process.env.API_URL}/blogs`, {
    next: { tags: ["blogs"] },
  });
  return response.json();
}

export default async function BlogsPage() {
  const { data: blogs } = await getBlogs();
  return <BlogList blogs={blogs} />;
}

3. Dynamic Metadata

// app/layout.js
async function fetchMetaData() {
  const response = await fetch(
    `${process.env.API_URL}/settings/admin/prefix`,
    { next: { tags: ["settings-group"] } }
  );
  return response.json();
}

export async function generateMetadata() {
  const settings = await fetchMetaData();
  return {
    title: settings?.admin?.meta_title || "SaaS App",
    description: settings?.admin?.meta_description,
  };
}

Cache Revalidation System

Server-side cache (Next.js fetch tags) is revalidated on-demand via server actions.

Server Actions

// src/actions/revalidate.js
"use server";
import { revalidateTag } from "next/cache";

// Revalidate a single tag
export async function revalidate(tag) {
  revalidateTag(tag);
}

// Revalidate multiple tags at once
export async function revalidateBulk(tags) {
  tags.forEach((tag) => revalidateTag(tag));
}

// Nuclear option: revalidate all 7 tags
export async function revalidateAll() {
  const allTags = [
    "blogs", "blog-details", "dynamic-page",
    "navigation-links", "packages-data",
    "settings-group", "all-settings",
  ];
  allTags.forEach((tag) => revalidateTag(tag));
}

Revalidation Triggers

Mutation Tag(s) Revalidated
Blog create/update blogs
Blog detail change blog-details
Settings update settings-group
Menu item changes navigation-links (via revalidateNavigationLinks())
Admin manual purge All tags (via /dashboard/settings/revalidate UI)

Admin Revalidation UI

The dashboard provides a manual cache purge interface at /dashboard/settings/revalidate, allowing admins to selectively or fully invalidate cached server-side data.

Permission Checking

Backend: Route Permission

// Backend middleware checks permissions automatically
router.get("/users", auth(), hasPermission(), UserController.getUsers);
// Requires: /users permission for GET
// Requires: /users/create for POST, /users/edit for PATCH, etc.

Frontend: UI Permission

// hooks/useCheckPermission.js
import { useSelector } from "react-redux";

export const useCheckPermission = (permission) => {
  const { user } = useSelector((state) => state.auth);

  if (!user) return false;
  if (user.role === "super_admin") return true;

  return user.permissions?.includes(permission);
};

// Usage in component
function AdminButton() {
  const canManageUsers = useCheckPermission("/users/create");

  if (!canManageUsers) return null;

  return <button>Add User</button>;
}

Dynamic Navigation

// Dashboard sidebar filters menu items by permission
const { data: links } = useNavigationLinks("dashboard-sidebar-top");

// Backend filters items based on user's permissions
// MenuItem.permission field is checked against user's permissions

File Upload Flow

Frontend                          Backend                      Storage Provider
   │                                 │                              │
   │ POST /uploads/image             │                              │
   │ FormData with file              │                              │
   ├───────────────────────────────>│                              │
   │                                │ multer (memory storage)      │
   │                                │ Validate: image-only types   │
   │                                │                              │
   │                                │ StorageFactory resolves:     │
   │                                │  ┌─ CloudinaryStorage        │
   │                                │  │  (auto WebP conversion)   │
   │                                │  ├─ AwsStorage               │
   │                                │  └─ LocalStorage             │
   │                                │                              │
   │                                │ Upload to provider           │
   │                                ├─────────────────────────────>│
   │                                │                              │
   │                                │ {public_id, url}             │
   │                                │<─────────────────────────────┤
   │                                │                              │
   │ {url, publicId}                │                              │
   │<───────────────────────────────┤                              │
   │                                │                              │
   │ Use URL in form                │                              │
// Frontend: uploadFile.js
const uploadFile = async (file) => {
  const formData = new FormData();
  formData.append("file", file);

  // Endpoint: POST ${NEXT_PUBLIC_API_URL}/uploads/image
  const response = await api.post("/uploads/image", formData, {
    headers: { "Content-Type": "multipart/form-data" },
  });

  return response.data.data.url;
};

Backend Storage Providers (factory pattern via STORAGE_PROVIDER env): - CloudinaryStorage: Auto-converts uploads to WebP format - AwsStorage: S3 bucket upload - LocalStorage: Saves to local filesystem - All providers enforce image-only file types

Payment Integration

Stripe Checkout

Frontend                    Backend                         Stripe
   │                           │                               │
   │ POST /payments/checkout   │                               │
   │ {packageId, type}         │                               │
   ├──────────────────────────>│                               │
   │                           │ PaymentFactory.ts              │
   │                           │ -> StripeService               │
   │                           │ Create checkout session        │
   │                           ├──────────────────────────────>│
   │                           │                               │
   │                           │ {url, sessionId}              │
   │                           │<──────────────────────────────┤
   │                           │                               │
   │ {checkoutUrl, sessionId}  │                               │
   │<──────────────────────────┤                               │
   │                           │                               │
   │ Redirect to checkoutUrl   │                               │
   ├───────────────────────────────────────────────────────────>
   │                           │                               │
   │                           │  Webhook: payment.success     │
   │                           │<──────────────────────────────┤
   │                           │  Update subscription          │
   │                           │                               │

Payment Factory

src/lib/payment/PaymentFactory.ts
├── StripeService    — fully implemented (checkout, webhooks, subscriptions)
└── LemonSqueezy    — SDK setup exists, but NOT wired into factory (placeholder)

Webhook Processing

// Backend: POST /webhooks/stripe (root level, not under /api/v1)
app.post("/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sig = req.headers["stripe-signature"];
    const event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);

    switch (event.type) {
      case "checkout.session.completed":
        await handleCheckoutComplete(event.data.object);
        break;
      case "customer.subscription.updated":
        await handleSubscriptionUpdate(event.data.object);
        break;
    }

    res.json({ received: true });
  }
);

Known Issue: The webhook secret has a hardcoded fallback value when STRIPE_WEBHOOK_SECRET is not set, which is a security risk in production.

Frontend Stripe Keys

# Required in frontend .env
STRIPE_PUBLISHABLE_KEY=pk_test_...

Email Integration

Email Factory

src/lib/email/EmailFactory.ts
├── NodeMailerService   — SMTP-based (default)
├── ResendService       — Resend API
└── BrevoService        — Brevo (Sendinblue) API

Event-Driven Architecture

User Action
EventEmitter.emit("user.registered", { user, token })
Event Handler (src/subscribers/)
EmailFactory.create(provider) -> NodeMailerService | ResendService | BrevoService
Send email using hardcoded templates from src/template/ (5 template files)

Known Issue: The EmailTemplate database entity (with visual builder in the admin UI) is completely disconnected from the email sending pipeline. All emails are sent using hardcoded templates in src/template/. The DB templates and visual builder are a dead end.

CORS Configuration

Backend CORS Setup

// app.ts
import cors from "cors";
import { getCorsOrigins } from "./shared/getCorsOrigins";

app.use(cors({
  origin: getCorsOrigins(),  // Parses ALLOWED_ORIGINS env var (JSON array)
  credentials: true,         // Allow cookies
}));

Default CORS origins (when ALLOWED_ORIGINS is not set):

["http://localhost:3000", "http://localhost:3001"]

Frontend Credentials

// axiosInstance.js
const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  withCredentials: true,  // Send cookies with requests
});

Error Handling

Backend Error Response

// ApiError class
throw new ApiError(httpStatus.NOT_FOUND, "User not found");

// Global error handler response shape
{
  "success": false,
  "message": "User not found",
  "error": {
    "path": "/api/v1/users/123",
    "message": "User not found"
  }
}

Frontend Error Handling

// In React Query mutation
const createUser = useCreateUser();

const handleSubmit = async (data) => {
  try {
    await createUser.mutateAsync(data);
    toast.success("User created successfully");
  } catch (error) {
    toast.error(error.response?.data?.message || "Failed to create user");
  }
};

Known Integration Issues

# Severity Issue Impact
1 Critical JWT stored in non-httpOnly cookie XSS can steal the auth token directly via document.cookie. Frontend reads it with getCookie(), so httpOnly cannot be added without rearchitecting auth.
2 High 403 triggers full logout (same as 401) Users lose their session when they merely lack a specific permission, rather than only on actual auth failures.
3 High Triple-cache desync React Query, Redux, and Next.js fetch cache all store overlapping data (e.g., user profile, settings) with no coordination. Mutations may update one cache but leave others stale.
4 Medium 4 legacy hooks double-prefix API URL Some older hooks prepend NEXT_PUBLIC_API_URL to paths that already include the base URL, resulting in URLs like .../api/v1/http://localhost:5500/api/v1/....
5 Low useMenu passes wrong param name Sends limits instead of limit, causing the backend to ignore the pagination parameter.
6 Low LemonSqueezy placeholder SDK setup exists but factory integration is not implemented — calling it will fail silently or throw.

Deployment Considerations

Production URLs

# Frontend (.env.production)
NEXT_PUBLIC_API_URL=https://api.yourdomain.com/api/v1
API_URL=https://api.yourdomain.com/api/v1
STRIPE_PUBLISHABLE_KEY=pk_live_...

# Backend (.env.production)
CLIENT_URL=https://app.yourdomain.com
ALLOWED_ORIGINS=["https://app.yourdomain.com"]
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_live_...

Important: The current auth architecture stores the JWT in a JS-accessible cookie — the frontend reads it via getCookie("token") and sets it via setCookie(). This means httpOnly cannot be used without reworking the auth flow. If migrating to httpOnly cookies, the backend must set the cookie in the response header and the frontend must stop reading the token directly.

// Current architecture (JS-accessible cookie):
setCookie("token", value, {
  maxAge: 60 * 60 * 24 * 30,  // 30 days (rememberMe) or 7 days
  secure: true,                // HTTPS only (auto in production)
  sameSite: "strict",          // Prevent CSRF
  path: "/",
});
// Note: httpOnly is intentionally omitted — frontend JS reads this cookie.
// Mitigate XSS risk via CSP headers, input sanitization, and content security policies.

Cache Revalidation in Production

When deploying to Vercel or similar platforms, the revalidateTag() server actions work across the CDN edge. The admin UI at /dashboard/settings/revalidate provides manual purge capabilities for all 7 cache tags.


Generated by BMAD Document Project workflow v1.2.0 — Exhaustive scan, 2026-02-12 | Last verified: 2026-03-26