Next.js is a React framework that enables you to build server-rendered applications, static websites, and more.

Let’s dive into some common interview questions for a Senior Engineer role focusing on Next.js, going beyond the surface level.

Understanding Rendering Strategies

Question: Explain the different rendering strategies in Next.js and when you would use each.

Answer: Next.js offers several rendering strategies, each with its own trade-offs:

  • Server-Side Rendering (SSR): The page is rendered on the server for each request. This is ideal for dynamic content that changes frequently, such as user dashboards, personalized content, or e-commerce product pages where real-time stock information is crucial.

    • How it works: When a user requests a page, the Next.js server fetches the necessary data, renders the React component into HTML, and sends the complete HTML to the client. The client then hydrates the HTML with React.
    • Use case: Dynamic, personalized content.
  • Static Site Generation (SSG): The page is pre-rendered into HTML at build time. This is perfect for content that doesn’t change often, like blog posts, marketing pages, or documentation. SSG offers the best performance as the HTML is served directly from a CDN.

    • How it works: During the next build process, Next.js fetches data and renders pages into static HTML files. These files are then deployed to a CDN.
    • Use case: Content that rarely changes, maximum performance.
  • Incremental Static Regeneration (ISR): A hybrid approach that allows you to update static pages after the site has been built. You can specify a revalidate interval, and Next.js will re-render the page in the background after that time has passed, invalidating the old static version. This is great for content that needs to be relatively fresh but doesn’t require real-time updates, like news articles or product listings that update a few times a day.

    • How it works: After the initial build, Next.js serves the static page. When the revalidate time elapses, the next request triggers a background re-render. Once complete, subsequent requests serve the new version.
    • Use case: Content that needs to be fresh but not real-time.
  • Client-Side Rendering (CSR): The traditional React approach where the JavaScript bundle is downloaded, and the page is rendered in the browser. Next.js allows you to use CSR for specific components or pages, often within an SSR or SSG page.

    • How it works: The server sends a minimal HTML shell and JavaScript. The browser downloads the JavaScript, executes it, and then renders the content.
    • Use case: Highly interactive UIs, dashboards where initial load time isn’t paramount, or parts of a page that depend on user interaction.

Data Fetching in Next.js

Question: Contrast getServerSideProps, getStaticProps, and client-side data fetching.

Answer:

  • getServerSideProps:

    • Execution: Runs on the server for every incoming request.
    • Purpose: Fetching dynamic, request-specific data.
    • Example: Fetching user-specific data based on cookies or query parameters.
    • Considerations: Slower initial load compared to SSG because data fetching happens on each request.
    // pages/dashboard.js
    export async function getServerSideProps(context) {
      const userId = context.req.cookies.userId; // Example: Get user ID from cookies
      const res = await fetch(`https://api.example.com/users/${userId}/dashboard`);
      const dashboardData = await res.json();
    
      return {
        props: {
          dashboardData,
        },
      };
    }
    
    function Dashboard({ dashboardData }) {
      // Render dashboard using dashboardData
      return <div>{dashboardData.title}</div>;
    }
    
    export default Dashboard;
    
  • getStaticProps:

    • Execution: Runs at build time.
    • Purpose: Fetching data for static pages.
    • Example: Fetching a list of blog posts from a CMS.
    • Considerations: Best performance, but data is not updated until a new build or ISR is configured.
    // pages/posts.js
    export async function getStaticProps() {
      const res = await fetch('https://api.example.com/posts');
      const posts = await res.json();
    
      return {
        props: {
          posts,
        },
        revalidate: 60, // Re-generate page every 60 seconds (ISR)
      };
    }
    
    function Posts({ posts }) {
      // Render posts
      return (
        <ul>
          {posts.map((post) => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      );
    }
    
    export default Posts;
    
  • Client-Side Data Fetching:

    • Execution: Runs in the browser after the initial page load.
    • Purpose: Fetching data that doesn’t affect the initial render or is user-interactive.
    • Tools: useEffect hook with fetch, SWR, React Query.
    • Considerations: Best for data that doesn’t need to be present for SEO or initial render, or for data that changes frequently and is fetched interactively.
    // components/UserProfile.js
    import { useEffect, useState } from 'react';
    
    function UserProfile({ userId }) {
      const [user, setUser] = useState(null);
    
      useEffect(() => {
        async function fetchUser() {
          const res = await fetch(`https://api.example.com/users/${userId}`);
          const userData = await res.json();
          setUser(userData);
        }
        fetchUser();
      }, [userId]); // Re-fetch if userId changes
    
      if (!user) return <div>Loading...</div>;
    
      return <div>{user.name}</div>;
    }
    
    export default UserProfile;
    

API Routes

Question: How do Next.js API Routes work, and what are their limitations?

Answer: Next.js API Routes allow you to build backend API endpoints directly within your Next.js application. They live in the pages/api directory and are treated as serverless functions.

  • How they work: When you create a file like pages/api/users.js, Next.js automatically turns it into an API endpoint accessible at /api/users. The default export of this file should be a function that receives req (IncomingMessage) and res (ServerResponse) objects, similar to Node.js HTTP modules.

    // pages/api/users.js
    export default function handler(req, res) {
      const { method } = req;
    
      switch (method) {
        case 'GET':
          // Fetch users from a database
          res.status(200).json([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]);
          break;
        case 'POST':
          // Create a new user
          const newUser = req.body;
          res.status(201).json(newUser);
          break;
        default:
          res.setHeader('Allow', ['GET', 'POST']);
          res.status(405).end(`Method ${method} Not Allowed`);
      }
    }
    
  • Limitations:

    • Serverless Nature: API Routes are typically deployed as serverless functions. This means they have cold start times, limited execution duration (e.g., 60 seconds on Vercel), and statelessness. They are not suitable for long-running processes or stateful applications.
    • Build Time: API routes are bundled with your Next.js application at build time. If you have a very large number of API routes, it can increase build times.
    • No Native WebSockets: While you can implement WebSockets, it’s not as straightforward as in a dedicated Node.js server due to the serverless nature and potential connection limits.
    • Limited Access to Server Environment: You don’t have direct access to the underlying server process as you would with a traditional Node.js server.

Middleware

Question: Describe the use cases and implementation of Next.js Middleware.

Answer: Next.js Middleware allows you to run code before a request is completed, giving you the power to authenticate users, rewrite URLs, redirect users, or even modify responses. It runs at the edge, closer to the user, for improved performance.

  • Use Cases:

    • Authentication: Protecting routes by checking for valid session tokens or JWTs.
    • Authorization: Redirecting users to login pages if they are not authenticated.
    • URL Rewriting/Redirecting: Implementing A/B testing, localization, or dynamic routing.
    • Header Manipulation: Adding custom headers for security or analytics.
    • Geo-based Routing: Redirecting users based on their country.
  • Implementation: Middleware is defined in a middleware.js (or .ts) file at the root of your project or in a src directory. It exports an async function that receives a NextRequest object and returns a NextResponse object or a promise that resolves to one.

    // middleware.js
    import { NextResponse } from 'next/server';
    
    export function middleware(request) {
      const pathname = request.nextUrl.pathname;
    
      // Protect routes that require authentication
      const protectedRoutes = ['/dashboard', '/account'];
      const isProtectedRoute = protectedRoutes.some(route => pathname.startsWith(route));
    
      if (isProtectedRoute) {
        const sessionToken = request.cookies.get('session_token');
        if (!sessionToken) {
          // Redirect to login page if no session token
          const url = request.nextUrl.clone();
          url.pathname = '/login';
          return NextResponse.redirect(url);
        }
      }
    
      // Example: Rewrite /blog to /articles
      if (pathname.startsWith('/blog')) {
        const url = request.nextUrl.clone();
        url.pathname = pathname.replace('/blog', '/articles');
        return NextResponse.rewrite(url);
      }
    
      // Allow the request to proceed
      return NextResponse.next();
    }
    
    // Configure which paths the middleware should run on
    export const config = {
      matcher: ['/dashboard/:path*', '/account/:path*', '/blog/:path*'],
    };
    

    The config.matcher array specifies which paths the middleware should execute for. Using NextResponse.rewrite is crucial for internal URL manipulation without changing the URL in the browser’s address bar, which is different from NextResponse.redirect.

Optimizing Performance

Question: How do you optimize Next.js applications for performance, specifically concerning image and script loading?

Answer: Next.js provides built-in optimizations for images and scripts.

  • Image Optimization (next/image):

    • What it does: The next/image component automatically optimizes images. It resizes, optimizes (e.g., WebP format), and serves images in modern formats with lazy loading by default. This significantly improves Core Web Vitals.
    • How to use: Replace standard <img> tags with <Image> from next/image.
    // pages/index.js
    import Image from 'next/image';
    import profilePic from '../public/me.png';
    
    function HomePage() {
      return (
        <div>
          <h1>Welcome!</h1>
          <Image
            src={profilePic} // Local image
            alt="Picture of the author"
            width={500} // Required
            height={500} // Required
          />
          <Image
            src="https://images.unsplash.com/photo-1507525428034-b723cf961d3e" // Remote image
            alt="Beach"
            width={700}
            height={400}
            priority // Load this image with high priority (e.g., for hero images)
          />
        </div>
      );
    }
    
    export default HomePage;
    
    • Key benefits: Reduced file sizes, faster loading, better user experience. width and height props prevent layout shift. priority prop tells Next.js to preload the image.
  • Script Loading (next/script):

    • What it does: The next/script component offers more control over how third-party scripts are loaded, preventing them from blocking the rendering of your page.
    • Strategies:
      • afterInteractive: Loads the script after the page is interactive, without blocking rendering. Ideal for analytics or chat widgets.
      • lazyOnload: Loads the script after the page has finished loading. Good for non-critical scripts.
      • beforeInteractive: Loads the script before the page becomes interactive. Useful for scripts that need to be available early but don’t block the initial render.
      • worker: Loads the script in a web worker.
    // pages/_document.js (for global scripts) or any page
    import Document, { Html, Head, Main, NextScript } from 'next/document';
    import Script from 'next/script';
    
    class MyDocument extends Document {
      render() {
        return (
          <Html>
            <Head />
            <body>
              <Main />
              <NextScript />
              {/* Example: Load an analytics script after interaction */}
              <Script
                id="my-analytics-script"
                strategy="afterInteractive"
                src="https://www.example.com/analytics.js"
              />
              {/* Example: Load a chat widget after the page has loaded */}
              <Script
                id="chat-widget"
                strategy="lazyOnload"
                src="https://www.example.com/chat.js"
              />
            </body>
          </Html>
        );
      }
    }
    
    export default MyDocument;
    
    • Key benefits: Prevents render-blocking JavaScript, improves initial page load time, and offers fine-grained control over script loading behavior.

Server Components vs. Client Components (App Router)

Question: In the context of the App Router, what’s the fundamental difference between Server Components and Client Components, and how do you choose between them?

Answer: The App Router introduces Server Components (the default) and Client Components.

  • Server Components:

    • Execution: Rendered entirely on the server. They never run in the browser.
    • Benefits:
      • No client-side JavaScript: Reduces the JavaScript bundle size sent to the client, leading to faster initial loads.
      • Direct data access: Can directly async/await data fetching from databases or backend services without needing an API layer.
      • Access to server-side resources: Can import and use Node.js modules or sensitive environment variables.
    • Limitations: Cannot use React Hooks (like useState, useEffect), browser-specific APIs (like window), or event listeners.
  • Client Components:

    • Execution: Rendered on the server initially (for SSR/SSG) and then "hydrated" and run on the client.
    • How to opt-in: Mark a file with the "use client"; directive at the top.
    • Benefits: Can use React Hooks, event listeners, browser APIs, and interactive features.
    • Limitations: Adds JavaScript to the client bundle. Requires careful consideration for performance.
  • Choosing:

    • Default to Server Components: Start with Server Components for pages and components that don’t require interactivity or browser APIs. This maximizes performance.
    • Opt into Client Components when needed: If a component needs state, event handlers, or browser APIs (e.g., a form, a modal, a button with complex logic, or a component using useState/useEffect), mark it as a Client Component.
    • Component Hierarchy: A Server Component can render Client Components, but a Client Component cannot render Server Components directly. You pass Server Components as children to Client Components.
    // app/page.js (Server Component - Default)
    import ProductList from './ProductList'; // Assume ProductList is a Client Component
    import { fetchProducts } from '../lib/data'; // Direct data fetching on server
    
    async function HomePage() {
      const products = await fetchProducts(); // Data fetched on the server
    
      return (
        <div>
          <h1>Our Products</h1>
          {/* Pass Server Component data to Client Component */}
          <ProductList products={products} />
        </div>
      );
    }
    
    export default HomePage;
    
    // app/ProductList.js (Client Component)
    "use client"; // Mark as Client Component
    
    import { useState } from 'react';
    
    export default function ProductList({ products }) {
      const [filter, setFilter] = useState('');
    
      // Example of client-side interactivity
      const filteredProducts = products.filter(p =>
        p.name.toLowerCase().includes(filter.toLowerCase())
      );
    
      return (
        <div>
          <input
            type="text"
            placeholder="Filter products..."
            value={filter}
            onChange={(e) => setFilter(e.target.value)}
          />
          <ul>
            {filteredProducts.map(product => (
              <li key={product.id}>{product.name}</li>
            ))}
          </ul>
        </div>
      );
    }
    

The next logical step after mastering these concepts is to explore advanced routing patterns and layout management within the App Router.

Want structured learning?

Take the full Nextjs course →