Harith Zahid

Common Next.js Interview Questions for Mid-Level Frontend Developer (Next.js 15+)

1. Explain the App Router vs Pages Router in Next.js

Answer: Next.js 13+ introduced the App Router (app directory) as a new paradigm built on React Server Components. The Pages Router (pages directory) is the traditional routing system. The App Router provides better performance, automatic code splitting, and native support for Server Components.

Code Example:

// App Router (app directory) - Next.js 15
// app/page.js - Server Component by default
export default function HomePage() {
  return <h1>Home Page</h1>;
}

// app/dashboard/page.js - Nested route
export default function DashboardPage() {
  return <h1>Dashboard</h1>;
}

// app/layout.js - Root layout
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

// Pages Router (legacy but still supported)
// pages/index.js
export default function Home() {
  return <h1>Home Page</h1>;
}

// pages/dashboard.js
export default function Dashboard() {
  return <h1>Dashboard</h1>;
}

2. What are Server Components and Client Components?

Answer:

  • Server Components: Render on the server, can access backend resources directly, reduce JavaScript bundle size, and are the default in App Router
  • Client Components: Render on the client, can use hooks and interactivity, marked with 'use client' directive

Code Example:

// Server Component (default in app directory)
// app/posts/page.js
import { db } from '@/lib/db';

export default async function PostsPage() {
  // Direct database access - runs on server only
  const posts = await db.post.findMany();
  
  return (
    <div>
      <h1>Posts</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </article>
      ))}
    </div>
  );
}

// Client Component
// app/components/LikeButton.js
'use client';

import { useState } from 'react';

export default function LikeButton({ postId }) {
  const [likes, setLikes] = useState(0);
  const [isLiked, setIsLiked] = useState(false);

  const handleLike = async () => {
    setIsLiked(!isLiked);
    setLikes(prev => isLiked ? prev - 1 : prev + 1);
    
    await fetch(`/api/posts/${postId}/like`, {
      method: 'POST'
    });
  };

  return (
    <button onClick={handleLike}>
      {isLiked ? '❤️' : '🤍'} {likes}
    </button>
  );
}

// Combining Server and Client Components
// app/posts/[id]/page.js
import { db } from '@/lib/db';
import LikeButton from '@/components/LikeButton';

export default async function PostPage({ params }) {
  const post = await db.post.findUnique({
    where: { id: params.id }
  });

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <LikeButton postId={post.id} />
    </article>
  );
}

3. Explain Server Actions in Next.js 15

Answer: Server Actions are asynchronous functions that run on the server and can be called from Client or Server Components. They enable you to mutate data, revalidate cache, and handle form submissions without creating API routes.

Code Example:

// app/actions/posts.js
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createPost(formData) {
  const title = formData.get('title');
  const content = formData.get('content');

  // Validate data
  if (!title || !content) {
    return { error: 'Title and content are required' };
  }

  try {
    const post = await db.post.create({
      data: { title, content }
    });

    // Revalidate the posts page cache
    revalidatePath('/posts');
    
    return { success: true, post };
  } catch (error) {
    return { error: 'Failed to create post' };
  }
}

export async function deletePost(postId) {
  await db.post.delete({
    where: { id: postId }
  });

  revalidatePath('/posts');
}

// Using in a Server Component with forms
// app/posts/new/page.js
import { createPost } from '@/actions/posts';
import { redirect } from 'next/navigation';

export default function NewPostPage() {
  async function handleSubmit(formData) {
    'use server';
    
    const result = await createPost(formData);
    
    if (result.success) {
      redirect('/posts');
    }
  }

  return (
    <form action={handleSubmit}>
      <input type="text" name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit">Create Post</button>
    </form>
  );
}

// Using in a Client Component
// app/components/DeleteButton.js
'use client';

import { deletePost } from '@/actions/posts';
import { useTransition } from 'react';

export default function DeleteButton({ postId }) {
  const [isPending, startTransition] = useTransition();

  const handleDelete = () => {
    startTransition(async () => {
      await deletePost(postId);
    });
  };

  return (
    <button onClick={handleDelete} disabled={isPending}>
      {isPending ? 'Deleting...' : 'Delete'}
    </button>
  );
}

4. How does caching work in Next.js 15?

Answer: Next.js 15 has multiple caching layers:

  • Request Memoization: Deduplicates requests in a render pass
  • Data Cache: Persists data fetching results across requests
  • Full Route Cache: Caches rendered routes at build time
  • Router Cache: Client-side cache of route segments

Code Example:

// app/products/page.js
import { unstable_cache } from 'next/cache';

// Basic fetch with caching
export default async function ProductsPage() {
  // Cached for 1 hour
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 }
  });
  const products = await res.json();

  return (
    <div>
      {products.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  );
}

// Using unstable_cache for database queries
const getCachedProducts = unstable_cache(
  async () => {
    const products = await db.product.findMany();
    return products;
  },
  ['products-list'],
  {
    revalidate: 3600,
    tags: ['products']
  }
);

export async function ProductsList() {
  const products = await getCachedProducts();
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

// Revalidating cache with Server Actions
// app/actions/products.js
'use server';

import { revalidateTag, revalidatePath } from 'next/cache';

export async function updateProduct(productId, data) {
  await db.product.update({
    where: { id: productId },
    data
  });

  // Revalidate by tag
  revalidateTag('products');
  
  // Or revalidate by path
  revalidatePath('/products');
}

// Opt out of caching
export async function DynamicComponent() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'no-store' // Don't cache this request
  });
  const data = await res.json();

  return <div>{data.value}</div>;
}

// Force dynamic rendering for entire route
// app/dashboard/page.js
export const dynamic = 'force-dynamic'; // or 'auto', 'force-static'
export const revalidate = 0; // Revalidate on every request

export default async function DashboardPage() {
  const data = await fetch('https://api.example.com/dashboard');
  return <div>Dashboard</div>;
}

5. Explain Dynamic Routes and Parallel Routes

Answer:

  • Dynamic Routes: Create routes based on dynamic data using brackets [param]
  • Parallel Routes: Render multiple pages in the same layout simultaneously using @folder convention

Code Example:

// Dynamic Routes
// app/posts/[id]/page.js
export default async function PostPage({ params }) {
  const { id } = await params;
  const post = await fetch(`https://api.example.com/posts/${id}`).then(r => r.json());
  
  return <h1>{post.title}</h1>;
}

// Generate static params at build time
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  
  return posts.map(post => ({
    id: post.id.toString()
  }));
}

// Catch-all routes: app/blog/[...slug]/page.js
export default async function BlogPost({ params }) {
  const { slug } = await params;
  // slug can be ['2024', '01', 'my-post']
  return <div>Slug: {slug.join('/')}</div>;
}

// Optional catch-all: app/shop/[[...categories]]/page.js
export default async function ShopPage({ params }) {
  const { categories } = await params;
  // Matches /shop, /shop/electronics, /shop/electronics/phones
  return <div>Categories: {categories?.join('/') || 'All'}</div>;
}

// Parallel Routes
// app/layout.js
export default function DashboardLayout({
  children,
  analytics, // from @analytics
  team       // from @team
}) {
  return (
    <div>
      <div>{children}</div>
      <div className="grid">
        <div>{analytics}</div>
        <div>{team}</div>
      </div>
    </div>
  );
}

// app/@analytics/page.js
export default function AnalyticsSlot() {
  return (
    <div>
      <h2>Analytics</h2>
      <p>Sales data here</p>
    </div>
  );
}

// app/@team/page.js
export default function TeamSlot() {
  return (
    <div>
      <h2>Team</h2>
      <p>Team members here</p>
    </div>
  );
}

// app/page.js
export default function MainContent() {
  return <h1>Dashboard</h1>;
}

6. How do you handle loading and error states?

Answer: Next.js provides special files: loading.js for loading UI and error.js for error boundaries. These automatically wrap route segments in Suspense boundaries and Error Boundaries.

Code Example:

// app/dashboard/loading.js
export default function Loading() {
  return (
    <div className="loading">
      <div className="spinner" />
      <p>Loading dashboard...</p>
    </div>
  );
}

// app/dashboard/error.js
'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset
}) {
  useEffect(() => {
    console.error('Dashboard error:', error);
  }, [error]);

  return (
    <div className="error-container">
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>
        Try again
      </button>
    </div>
  );
}

// app/dashboard/not-found.js
import Link from 'next/link';

export default function NotFound() {
  return (
    <div>
      <h2>Dashboard Not Found</h2>
      <p>Could not find the requested dashboard.</p>
      <Link href="/">Return Home</Link>
    </div>
  );
}

// Using in page with notFound()
// app/posts/[id]/page.js
import { notFound } from 'next/navigation';

export default async function PostPage({ params }) {
  const { id } = await params;
  const post = await fetch(`https://api.example.com/posts/${id}`).then(r => r.json());
  
  if (!post) {
    notFound(); // Shows not-found.js
  }

  return <article>{post.title}</article>;
}

// Streaming with Suspense
// app/dashboard/page.js
import { Suspense } from 'react';

async function Analytics() {
  const data = await fetch('https://api.example.com/analytics', {
    cache: 'no-store'
  });
  return <div>Analytics: {data}</div>;
}

async function RecentSales() {
  const sales = await fetch('https://api.example.com/sales', {
    cache: 'no-store'
  });
  return <div>Sales: {sales}</div>;
}

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<div>Loading analytics...</div>}>
        <Analytics />
      </Suspense>
      <Suspense fallback={<div>Loading sales...</div>}>
        <RecentSales />
      </Suspense>
    </div>
  );
}

7. Explain Route Handlers (API Routes in App Router)

Answer: Route Handlers are the App Router equivalent of API routes. They're defined in route.js files and support standard HTTP methods (GET, POST, PUT, DELETE, etc.). They run on the server and can access backend resources.

Code Example:

// app/api/posts/route.js
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const page = searchParams.get('page') || '1';
  
  const posts = await db.post.findMany({
    skip: (parseInt(page) - 1) * 10,
    take: 10
  });

  return NextResponse.json({ posts, page });
}

export async function POST(request) {
  try {
    const body = await request.json();
    const { title, content } = body;

    if (!title || !content) {
      return NextResponse.json(
        { error: 'Title and content required' },
        { status: 400 }
      );
    }

    const post = await db.post.create({
      data: { title, content }
    });

    return NextResponse.json(post, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to create post' },
      { status: 500 }
    );
  }
}

// Dynamic route handler
// app/api/posts/[id]/route.js
export async function GET(request, { params }) {
  const { id } = await params;
  
  const post = await db.post.findUnique({
    where: { id }
  });

  if (!post) {
    return NextResponse.json(
      { error: 'Post not found' },
      { status: 404 }
    );
  }

  return NextResponse.json(post);
}

export async function PUT(request, { params }) {
  const { id } = await params;
  const body = await request.json();

  const post = await db.post.update({
    where: { id },
    data: body
  });

  return NextResponse.json(post);
}

export async function DELETE(request, { params }) {
  const { id } = await params;

  await db.post.delete({
    where: { id }
  });

  return NextResponse.json({ success: true });
}

// Handling headers and cookies
// app/api/auth/route.js
import { cookies } from 'next/headers';

export async function POST(request) {
  const body = await request.json();
  
  // Set cookie
  const cookieStore = await cookies();
  cookieStore.set('session', 'token-value', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 * 7 // 1 week
  });

  return NextResponse.json({ success: true });
}

export async function GET(request) {
  const cookieStore = await cookies();
  const session = cookieStore.get('session');

  return NextResponse.json({ session: session?.value });
}

// CORS configuration
// app/api/public/route.js
export async function GET(request) {
  return NextResponse.json(
    { message: 'Public API' },
    {
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type'
      }
    }
  );
}

8. How do you implement Middleware in Next.js?

Answer: Middleware runs before a request is completed, allowing you to modify the response. It's useful for authentication, redirects, rewrites, and adding headers. Middleware must be placed in the root or src directory as middleware.js.

Code Example:

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

export function middleware(request) {
  const { pathname } = request.nextUrl;

  // Authentication check
  const token = request.cookies.get('session')?.value;

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

  // Redirect logged-in users away from login
  if (pathname === '/login' && token) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  // Add custom header
  const response = NextResponse.next();
  response.headers.set('x-custom-header', 'my-value');

  return response;
}

// Configure which paths middleware runs on
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/login',
    '/api/:path*'
  ]
};

// Advanced middleware with geolocation
// middleware.js
import { NextResponse } from 'next/server';

export function middleware(request) {
  const country = request.geo?.country || 'US';
  const url = request.nextUrl;

  // Redirect based on country
  if (url.pathname === '/' && country === 'GB') {
    return NextResponse.redirect(new URL('/gb', request.url));
  }

  // Rate limiting example
  const ip = request.ip || 'unknown';
  const rateLimitKey = `rate-limit:${ip}`;
  
  // Add rate limit info to headers
  const response = NextResponse.next();
  response.headers.set('X-RateLimit-Limit', '100');
  response.headers.set('X-User-Country', country);
  
  return response;
}

// Chain multiple middleware logic
// middleware.js
import { NextResponse } from 'next/server';

function checkAuth(request) {
  const token = request.cookies.get('session')?.value;
  return !!token;
}

function checkRole(request, requiredRole) {
  const role = request.cookies.get('role')?.value;
  return role === requiredRole;
}

export function middleware(request) {
  const { pathname } = request.nextUrl;

  // Public routes
  if (pathname.startsWith('/public')) {
    return NextResponse.next();
  }

  // Protected routes
  if (!checkAuth(request)) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Admin-only routes
  if (pathname.startsWith('/admin') && !checkRole(request, 'admin')) {
    return NextResponse.redirect(new URL('/unauthorized', request.url));
  }

  // Rewrite example - show different content without changing URL
  if (pathname === '/old-path') {
    return NextResponse.rewrite(new URL('/new-path', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ]
};

9. How do you implement Internationalization (i18n)?

Answer: Next.js 15 App Router supports i18n through routing and content organization. You can use libraries like next-intl or implement a custom solution using dynamic segments and locale detection.

Code Example:

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

const locales = ['en', 'es', 'fr'];
const defaultLocale = 'en';

function getLocale(request) {
  // Check cookie
  const localeCookie = request.cookies.get('locale')?.value;
  if (localeCookie && locales.includes(localeCookie)) {
    return localeCookie;
  }

  // Check Accept-Language header
  const acceptLanguage = request.headers.get('accept-language');
  if (acceptLanguage) {
    const locale = acceptLanguage.split(',')[0].split('-')[0];
    if (locales.includes(locale)) return locale;
  }

  return defaultLocale;
}

export function middleware(request) {
  const { pathname } = request.nextUrl;

  // Skip if already has locale or is static file
  const pathnameHasLocale = locales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale || pathname.includes('.')) {
    return NextResponse.next();
  }

  // Redirect to locale
  const locale = getLocale(request);
  request.nextUrl.pathname = `/${locale}${pathname}`;
  return NextResponse.redirect(request.nextUrl);
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
};

// Locale layout
// app/[locale]/layout.js
export async function generateStaticParams() {
  return [{ locale: 'en' }, { locale: 'es' }, { locale: 'fr' }];
}

export default function LocaleLayout({ children, params }) {
  return (
    <html lang={params.locale}>
      <body>{children}</body>
    </html>
  );
}

// Dictionary system
// lib/dictionaries.js
const dictionaries = {
  en: () => import('@/dictionaries/en.json').then(m => m.default),
  es: () => import('@/dictionaries/es.json').then(m => m.default),
  fr: () => import('@/dictionaries/fr.json').then(m => m.default)
};

export const getDictionary = async (locale) => {
  return dictionaries[locale]?.() ?? dictionaries.en();
};

// dictionaries/en.json
{
  "home": {
    "title": "Welcome",
    "description": "This is the home page"
  },
  "nav": {
    "home": "Home",
    "about": "About",
    "contact": "Contact"
  }
}

// dictionaries/es.json
{
  "home": {
    "title": "Bienvenido",
    "description": "Esta es la página de inicio"
  },
  "nav": {
    "home": "Inicio",
    "about": "Acerca de",
    "contact": "Contacto"
  }
}

// Using in components
// app/[locale]/page.js
import { getDictionary } from '@/lib/dictionaries';

export default async function HomePage({ params }) {
  const { locale } = await params;
  const dict = await getDictionary(locale);

  return (
    <div>
      <h1>{dict.home.title}</h1>
      <p>{dict.home.description}</p>
    </div>
  );
}

// Language switcher component
// app/[locale]/components/LanguageSwitcher.js
'use client';

import { usePathname, useRouter } from 'next/navigation';

export default function LanguageSwitcher({ currentLocale }) {
  const pathname = usePathname();
  const router = useRouter();

  const switchLocale = (newLocale) => {
    // Remove current locale from pathname
    const segments = pathname.split('/');
    segments[1] = newLocale;
    const newPath = segments.join('/');
    
    // Set cookie and navigate
    document.cookie = `locale=${newLocale}; path=/; max-age=31536000`;
    router.push(newPath);
  };

  return (
    <select value={currentLocale} onChange={(e) => switchLocale(e.target.value)}>
      <option value="en">English</option>
      <option value="es">Español</option>
      <option value="fr">Français</option>
    </select>
  );
}

10. How do you optimize images and fonts in Next.js?

Answer: Next.js provides built-in Image optimization with the <Image> component and automatic font optimization with next/font. These features improve performance by serving optimized assets.

Code Example:

// Using next/image
// app/page.js
import Image from 'next/image';
import profilePic from '@/public/profile.jpg';

export default function HomePage() {
  return (
    <div>
      {/* Local image with import */}
      <Image
        src={profilePic}
        alt="Profile picture"
        width={500}
        height={500}
        priority // Load immediately for LCP
        placeholder="blur" // Automatic blur placeholder
      />

      {/* Remote image */}
      <Image
        src="https://example.com/photo.jpg"
        alt="Remote photo"
        width={800}
        height={600}
        quality={85} // Default is 75
        loading="lazy" // Default behavior
      />

      {/* Fill container */}
      <div style={{ position: 'relative', width: '100%', height: '400px' }}>
        <Image
          src="/hero.jpg"
          alt="Hero"
          fill
          style={{ objectFit: 'cover' }}
          sizes="100vw"
        />
      </div>

      {/* Responsive images */}
      <Image
        src="/responsive.jpg"
        alt="Responsive"
        width={1200}
        height={600}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      />
    </div>
  );
}

// Configure allowed image domains
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'example.com',
        port: '',
        pathname: '/images/**',
      },
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
      }
    ],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    formats: ['image/webp'], // or ['image/avif', 'image/webp']
  },
};

export default nextConfig;

// Using next/font
// app/layout.js
import { Inter, Roboto_Mono, Playfair_Display } from 'next/font/google';
import localFont from 'next/font/local';

// Google Fonts
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-roboto-mono',
});

// Local custom font
const myFont = localFont({
  src: [
    {
      path: './fonts/MyFont-Regular.woff2',
      weight: '400',
      style: 'normal',
    },
    {
      path: './fonts/MyFont-Bold.woff2',
      weight: '700',
      style: 'normal',
    },
  ],
  variable: '--font-my-font',
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={`${inter.variable} ${robotoMono.variable} ${myFont.variable}`}>
      <body className={inter.className}>
        {children}
      </body>
    </html>
  );
}

// Using fonts in CSS
// app/globals.css
:root {
  --font-inter: 'Inter', sans-serif;
  --font-roboto-mono: 'Roboto Mono', monospace;
}

.heading {
  font-family: var(--font-inter);
}

.code {
  font-family: var(--font-roboto-mono);
}

// Preload specific font weights
// app/layout.js
import { Poppins } from 'next/font/google';

const poppins = Poppins({
  weight: ['400', '600', '700'],
  subsets: ['latin'],
  display: 'swap',
  preload: true,
});

// Image component with loading states
// app/components/OptimizedImage.js
'use client';

import Image from 'next/image';
import { useState } from 'react';

export default function OptimizedImage({ src, alt, ...props }) {
  const [isLoading, setIsLoading] = useState(true);

  return (
    <div className="relative">
      <Image
        src={src}
        alt={alt}
        className={`transition-opacity duration-300 ${
          isLoading ? 'opacity-0' : 'opacity-100'
        }`}
        onLoad={() => setIsLoading(false)}
        {...props}
      />
      {isLoading && (
        <div className="absolute inset-0 bg-gray-200 animate-pulse" />
      )}
    </div>
  );
}

These questions cover the latest Next.js 15 features with the App Router, Server Components, Server Actions, and modern best practices. Understanding these concepts is crucial for mid-level Next.js developers.