The error handling starts deep inside your NestJS microservices and propagates all the way up to your React components. Here is the exact path it travels:
Your NestJS auth-service has three powerful exception filters configured in src/common/filters/:
- prisma-exception.filter.ts (Catches database errors)
- jwt-exception.filter.ts (Catches token expiration)
- global-exception.filter.ts (Catches everything else, including your generic AppException class).
The Magic: No matter what crashes the backend, these filters catch it and force the error into this exact mathematical shape (defined by CustomErrorResponseDto):
{
"statusCode": 401,
"message": "Token expired",
"errorCode": "ACCESS_TOKEN_EXPIRED",
"path": "/auth/profile"
}The AuthService makes an Axios call to the microservice. Axios sees the 401 status code and throws an AxiosError.
Your new handleAxiosError utility catches that exact error. It looks inside the AxiosError.response.data, sees the CustomErrorResponseDto from NestJS, and wraps it in a strongly typed ServiceError class.
The Service layer then returns return { data: null, error: ServiceError }.
The Route handler looks at the result:
if (result.error) {
// It takes the exact status and message from the ServiceError (originally from NestJS)
return NextResponse.json(
{ message: result.error.message, errorCode: result.error.errorCode },
{ status: result.error.statusCode }
);
}Note: We never throw errors here. We gracefully return them as JSON so the browser fetch doesn't crash catastrophically.
The Redux Thunk uses fetch('/api/auth/login').
It explicitly checks:
if (!response.ok) {
const data = await response.json(); // Extracts `{ message, errorCode }`
return rejectWithValue(data.message); // Throws the exact string back to the React component
}This was a generic placeholder you had inside the frontend codebase before this pure BFF architectural switch. Going forward, the ServiceError acts as the single source of truth for all API-failure exceptions. AppError can safely be ignored or deleted unless you want to use it for strictly client-side problems (e.g., throwing a validation error before the request even leaves the browser).
It's common to get confused about where a file belongs in a Next.js App Router codebase. Here are the hard rules:
| Directory | Purpose | Can run in Browser? | Can run in Node (Server)? |
|---|---|---|---|
src/utils/ |
Generic helper functions. Pure math, string formatting, reusable date formatters, and pure error mappers like service-error.ts. They do not "do" anything infrastructural. | ✅ Yes | ✅ Yes |
src/lib/ |
Core business logic, constants, configured libraries (like Redux, specialized validation, UI libraries). Think "libraries" you write or wrap for the whole app. | ✅ Yes | ✅ Yes |
src/server/ |
STRICTLY SERVER. If it imports next/headers, cookies(), jose, or uses raw Axios to call a microservice, it goes here. The browser must never import from this folder. |
❌ No | ✅ Yes |
-
Is auth-guard.ts a utility? No. A utility is a generic, reusable helper (like
capitalizeString()). auth-guard.ts is an architectural pillar of your BFF security. It reads server-only request objects and parses JWTs using Node libraries. It perfectly belongs insrc/server/bff/(Backend-For-Frontend). -
Where do
lib/constantsgo? They stay exactly insrc/lib/constants. Since those constants (likeErrorCodesstrings) need to be imported by the Redux Thunk (Browser) and the Route Handler (Server),src/lib/is the correct place for cross-environment shared logic. -
Where does lib/jwt/verify.ts go? Technically, because it relies on the
joselibrary to verify backend secrets usingprocess.env, it is a server-only file. Moving it from src/lib/jwt/verify.ts tosrc/server/jwt/verify.tswould be the most architecturally pure decision to prevent accidental client-side imports.