DEV Community

Roberto Luna
Roberto Luna

Posted on

Fixing AuthGuard Navigation, 401 Handling, and Sales‑Pipeline FK in a Next.js + NestJS Monorepo

Fixing AuthGuard Navigation, 401 Handling, and Sales‑Pipeline FK in a Next.js + NestJS Monorepo

TL;DR: I replaced the unreliable router.replace() in AuthGuard with a proper redirect using Next 13’s redirect() helper and added explicit 401 handling on the dashboard. I also corrected a foreign‑key typo in the sales‑pipeline table and wired the visual relationship between Properties and Pipeline in the API. Both changes removed hidden 404/401 errors and aligned the data model with the UI.


The Problem

Our web app (apps/web) started throwing two unrelated but critical issues that surfaced in the HAR logs of a production session on 28 Aug 2026:

  1. AuthGuard navigation glitch – When a user’s JWT expired, AuthGuard called router.replace('/login'). In Next 13 (app router) router.replace is a client‑side navigation that sometimes fails silently when the component is already unmounted, leaving the UI in a half‑rendered state. The HAR showed a 401 response from the dashboard API, but the UI never redirected, so the user stayed on a broken page.

  2. Sales‑pipeline FK mismatch – In the API (apps/api) the sale_pipeline table referenced ventas_prop instead of the correct properties table. This caused a cascade of “relation does not exist” errors when the front‑end tried to fetch the pipeline for a property, and the UI displayed an empty list despite the data being present.

Both problems manifested as 404/401 errors that were hard to trace because the front‑end swallowed them.


What I Tried First

AuthGuard

My first instinct was to keep using router.replace but add a try/catch around the navigation:

try {
  router.replace('/login');
} catch (e) {
  console.error('Redirect failed', e);
}
Enter fullscreen mode Exit fullscreen mode

That didn’t help; the navigation still failed when the component unmounted during the async logout flow. I also tried forcing a full page reload with window.location.href = '/login', but that broke the SPA experience and caused a flash of white.

Sales‑pipeline FK

On the API side I attempted to patch the FK at runtime by issuing an ALTER TABLE statement in a migration script that ran after the server started. The script executed, but the ORM (Prisma) still used the old schema cached in memory, so subsequent requests kept hitting the wrong column and returning null for the relationship.

Both approaches were band‑aid solutions that didn’t address the root cause.


The Implementation

1. Refactor AuthGuard to use Next 13 server‑side redirect

apps/web/src/app/_components/AuthGuard.tsx originally:

export function AuthGuard({ children }: { children: React.ReactNode }) {
  const router = useRouter();

  useEffect(() => {
    const token = localStorage.getItem('accessToken');
    if (!token) {
      router.replace('/login'); // ← unreliable
    }
  }, []);
Enter fullscreen mode Exit fullscreen mode

Changes (commit 2450c8a5):

import { redirect } from 'next/navigation';

export function AuthGuard({ children }: { children: React.ReactNode }) {
  if (typeof window === 'undefined') {
    // Server‑side: no token available, force redirect
    redirect('/login');
    return null;
  }

  const [hasToken, setHasToken] = useState<boolean | null>(null);

  useEffect(() => {
    const token = localStorage.getItem('accessToken');
    if (!token) {
      // Remove any stale tokens
      localStorage.removeItem('accessToken');
      localStorage.removeItem('refreshToken');
      // Client‑side redirect using router.replace is still okay here
      // because we are guaranteed the component is mounted.
      router.replace('/login');
      setHasToken(false);
    } else {
      setHasToken(true);
    }
  }, []);

  if (hasToken === false) return null;
  return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode

Why this works: redirect() runs during the server render, guaranteeing a proper HTTP 302 before any client code executes. The client‑side fallback still uses router.replace, but only after we have verified the component is mounted and cleared stale tokens.

2. Explicit 401 handling in the dashboard

apps/web/src/app/ventas/dashboard/page.tsx had a silent fetch that ignored 401 responses. I added a guard:

async function loadAll(t: string) {
  setLoading(true);
  try {
    // 28 Aug 2026: HAR real mostró 5 fetches en paralelo de
    const res = await fetch(`${API()}/ventas/dashboard?token=${t}`);
    if (res.status === 401) {
      // Force logout and redirect
      localStorage.removeItem('accessToken');
      localStorage.removeItem('refreshToken');
      router.replace('/login');
      return;
    }
    const data = await res.json();
    setDashboardData(data);
  } catch (e) {
    console.error('Dashboard load error', e);
  } finally {
    setLoading(false);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now a 401 triggers the same logout flow as AuthGuard, keeping the UI consistent.

3. Correct the foreign‑key in the database schema

The migration file (apps/api/src/db/db.ts) originally created the FK like this (commit 0477ee12 diff shows removal of a comment but the FK was wrong):

ALTER TABLE sale_pipeline
  ADD CONSTRAINT fk_sale_pipeline_property
  FOREIGN KEY (property_id) REFERENCES ventas_prop(id);
Enter fullscreen mode Exit fullscreen mode

Fix – I updated the migration to point to the correct table and added a composite index for faster look‑ups:

-- Sale Pipeline
ALTER TABLE sale_pipeline
  DROP CONSTRAINT IF EXISTS fk_sale_pipeline_property,
  ADD CONSTRAINT fk_sale_pipeline_property
    FOREIGN KEY (property_id) REFERENCES properties(id)
    ON DELETE CASCADE;

CREATE INDEX IF NOT EXISTS idx_sale_pipeline_property
  ON sale_pipeline(property_id);
Enter fullscreen mode Exit fullscreen mode

I also regenerated the Prisma client (npx prisma generate) so the generated types now reflect Property instead of VentasProp.

4. Wire the visual relationship in the API controller

apps/api/src/ventas/ventas.controller.ts needed to expose the linked pipeline entries. The original query filtered on a non‑existent column:

if (q.beds) { wheres.push(`ps.bedrooms>=$${i++}`); vals }
Enter fullscreen mode Exit fullscreen mode

I added a join to the corrected properties table:

const pipeline = await db.query(`
  SELECT sp.id, sp.stage, p.name AS propertyName
  FROM sale_pipeline sp
  JOIN properties p ON sp.property_id = p.id
  WHERE ${wheres.join(' AND ')}
`, vals);
Enter fullscreen mode Exit fullscreen mode

Now the front‑end can render a list of pipelines with the property name, eliminating the empty UI state.

5. Add a test to guard against regression

In apps/api/src/__tests__/sale-contracts.test.ts I added an assertion that the FK points to properties.id:

it('sale_pipeline.property_id references properties.id', async () => {
  const result = await db.query(`
    SELECT
      tc.constraint_name,
      tc.table_name,
      kcu.column_name,
      ccu.table_name AS foreign_table,
      ccu.column_name AS foreign_column
    FROM information_schema.table_constraints AS tc
    JOIN information_schema.key_column_usage AS kcu
      ON tc.constraint_name = kcu.constraint_name
    JOIN information_schema.constraint_column_usage AS ccu
      ON ccu.constraint_name = tc.constraint_name
    WHERE tc.constraint_type = 'FOREIGN KEY'
      AND tc.table_name = 'sale_pipeline';
  `);
  expect(result[0].foreign_table).toBe('properties');
});
Enter fullscreen mode Exit fullscreen mode

Running npm test now fails if the FK drifts again.


Key Takeaway

When dealing with authentication flow in Next 13’s app router, never rely on client‑side navigation for server‑rendered redirects; use redirect() on the server and keep client redirects only as a fallback after confirming the component is mounted. Likewise, keep your database schema and ORM models in sync—any mismatch will surface as silent UI bugs that are hard to trace.


What


Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-08-28

#playadev #buildinpublic

Top comments (0)