Skip to main content

Command Palette

Search for a command to run...

From CSR to SSR: Improving Frontend Performance of InterviewAI

Updated
6 min read
From CSR to SSR:  Improving Frontend Performance  of InterviewAI
P
I'm a Senior Frontend Developer. I write about Web, Next, React, JavaScript, etc

After the first version of InterviewAI, my main focus was to add more features to it as well as improve the user experience. In order to improve the load time of the app and improve the performance, I moved from traditional Client-Side Rendering (CSR) to a robust Server-Side Rendering (SSR) model in Next.js 16. This transition wasn't just about speed, it was about re-engineering our entire authentication and data-fetching layer to be resilient across subdomains.

Here is the technical blueprint of how I stabilized the architecture.


1. The Network Proxy Layer (proxy.ts)

We implemented our authentication logic in a central proxy.ts file using the new named export pattern.

This layer acts as a "Guardian" for every request, verifying sessions before they ever touch our UI components.

// proxy.ts (Next.js 16 Middleware)
export async function proxy(req: NextRequest) {
  const { pathname } = req.nextUrl;
  const accessToken = req.cookies.get("accessToken")?.value;
  const refreshToken = req.cookies.get("refreshToken")?.value;

  // Local JWT Verification (High performance, no network call)
  const isAuthenticated = await verifyToken(accessToken);

  // Proactive Session Refresh
  if (!isAuthenticated && refreshToken && isProtectedPath(pathname)) {
    return NextResponse.redirect(new URL("/api/refresh", req.url));
  }

  // Injecting session into Server Components
  const response = NextResponse.next();
  if (accessToken) {
    response.headers.set("x-access-token", accessToken);
  }
  return response;
}

The Silent Refresh Handshake

One of the most effective patterns is the "Middleware-to-API" refresh bridge. When the middleware detects an expired accessToken but a valid refreshToken, it doesn't just fail. Instead, it performs a redirection to a hidden internal API route (/api/refresh).

This internal route communicates with our express backend, secures a fresh set of cookies (with consistent root-domain settings), and then seamlessly redirects the user back to their intended destination. This happens so quickly that the user never sees a loading screen or a 401 error.


2. The Hybrid API Client (Bridging the Gap)

Fetching data on the server in Next.js is tricky because standard cookies aren't always available to the fetch context in the same way they are in the browser. We built a Hybrid API Client to solve this.

This client detects its execution environment. If it's on the server, it pulls the authenticated session from the x-access-token header we injected in the middleware. If it's in the browser, it relies on standard withCredentials behavior.

// lib/api.ts
const getHeaders = async () => {
  if (typeof window === "undefined") {
    // SERVER: Use the Token Bridge header from Middleware
    const { headers } = await import("next/headers");
    const headerList = await headers();
    const mwToken = headerList.get("x-access-token");
    return { Authorization: `Bearer ${mwToken}` };
  }
  // CLIENT: Let standard cookie exchange handle it
  return {};
};

This "Bridge" ensures that your Server Side Rendering logic is always just as authenticated as your Client Side logic, preventing data mismatches during hydration.


3. Distributed Authentication & Cookies

Our architecture spans multiple subdomains (e.g., www.interviewai.in.net and api.interviewai.in.net). To ensure a seamless session across these surfaces, we implemented a Shared Root Domain strategy.

Domain Alignment & SameSite Policies

By standardizing our cookies to the parent domain .interviewai.in.net, we ensured that a user logged into the Landing Page is instantly recognized in the Dashboard and AI Interview Room.

I utilized the SameSite=Lax policy for our production cookies. This provides a balance between security and cross-subdomain compatibility, ensuring tokens are sent reliably during internal redirects while protecting against CSRF attacks.


3. The React Query Dehydration Pattern

To make the UI available instantly without triggering extra fetches in the browser, I implemented the Dehydration/Hydration pattern with React Query.

Instead of the client showing a loading spinner while it fetches your profile, the server performs the initial fetch, serializes the state, and "dehydrates" it into the HTML cache.

  1. Server-Side Fetching: Fetch data on the server using queryClient.prefetchQuery.

  2. Dehydration: Convert the server cache into a JSON snapshot using the dehydrate function.

  3. Hydration Boundary: Wrap the page in a <HydrationBoundary>. The client wakes up with a pre-populated cache, making the first render instantaneous.

The Request-Scoped Query Client

In SSR, it's critical to create a new QueryClient for every request to avoid data leaking between users. We use React's cache() to ensure a single singleton per-request on the server.

// lib/react-query.ts
import { QueryClient, isServer } from "@tanstack/react-query";
import { cache } from 'react';

const _getQueryClient = cache(() => new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
    },
  },
}));

export function getQueryClient() {
  if (isServer) return _getQueryClient();
  if (!browserQueryClient) browserQueryClient = new QueryClient();
  return browserQueryClient;
}

Implementing the Boundary

In our layout.tsx, we pre-fetch the data and then wrap the children.

export default async function RootLayout({ children }) {
  const queryClient = getQueryClient();
  
  await queryClient.prefetchQuery({
    queryKey: ["auth-user"],
    queryFn: () => api.get("/user/me")
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {children}
    </HydrationBoundary>
  );
}

4. Solving the "Redirect Loop" issue

One of the most interesting challenges was a circular redirect that can occur between the Server Middleware and the Client-Side Auth Guards.

If the client hydrates with an "Empty User" state while the server middleware knows the user is logged in, you can get stuck in a loop. We solved this by synchronizing our identity check in the Root Layout. This ensures that the client-side hydration always has access to the verified session state from the server. This results in a stable, flicker-free user experience.


5. Summary: Performance Results

The transition to this SSR architecture wasn't just an architectural win; it was a performance breakthrough. Our Lighthouse scores reached a consistent 95+ across the board.

Performance Highlights:

  • Performance: 96 — The browser receives fully hydrated HTML, reducing JS execution time.

  • SEO: 100 — Every page is now fully crawlable with perfect metadata.

  • Best Practices: 100 — Modern SSR patterns ensured high-quality security and code standards.

Metric Before (CSR) After (SSR)
Performance 75 96
SEO 60 100
Best Practices 85 100
Accessibility 70 86

Key Takeaways for Next.js 16 SSR:

  1. Explicit Proxy Middleware: Use proxy.ts to manage your network boundaries with named exports.

  2. Dehydration is Key: Seed your client state from the server to eliminate loading states for primary session data.

  3. Standardized Domain Cookies: Use parent-domain cookies for stable auth across subdomains.