Land the Frontend Role You Want
Curated from 15+ years of hiring experience. Every question interviewers actually ask at 1+ YOE level — with real answers, code examples, and industry context.
At 1–3 years of experience, interviewers aren't expecting you to know everything. They're looking for three things: you understand why not just what, you can reason under uncertainty, and you've shipped real things and learned from them.
The biggest mistake candidates at this level make is memorising answers without understanding the tradeoffs. Every question in this guide is structured to help you explain the reasoning — because that's what separates a hire from a pass.
In every Next.js interview I've seen in 2024–25, this comes up in the first 10 minutes. It's not a gotcha — it's a genuine signal of whether you understand the modern React/Next.js mental model.
The thing most candidates get wrong: they explain what the difference is but can't explain the consequence on the page they're building. Practice connecting the concept to a real decision: "On our dashboard, I kept the sidebar as a Server Component because it only needs data, but the filter dropdowns became a Client Component because they need useState."
The three fetch cache options (force-cache, no-store, next: { revalidate }) are asked in almost every technical screen. Know them and know which renders SSG, SSR, and ISR behaviour.
Bonus points for mentioning revalidateTag vs revalidatePath — most 1 YOE candidates don't get this deep and it signals genuine production experience.
Product-minded companies care deeply about performance metrics because they tie directly to conversion and SEO. If you can say "we had a CLS issue because we were loading fonts without swap, and I fixed it by switching to next/font which self-hosts them at build time" — that's a story that lands.
Learn LCP, INP, and CLS. Know what Next.js features address each one. Be ready to describe a real performance improvement you made.
The app/ directory is the root of App Router. Each folder represents a route segment. A route becomes publicly accessible only when it contains a page.tsx file — other files in the folder (components, utils, tests) are colocated but not exposed as routes.
The special files that add behaviour to a segment:
- page.tsx — the UI for that route. The only file that makes a route accessible.
- layout.tsx — wraps the page and all children. Persists across navigations without re-mounting. Great for navbars, sidebars.
- loading.tsx — automatically wraps the page in a Suspense boundary. Shows while the page is loading. Renders instantly — no waiting for data.
- error.tsx — a React error boundary for that segment. Must be a Client Component (
'use client'). Catches errors in the page and children. - not-found.tsx — rendered when
notFound()is called from the page, or when no route matches. - template.tsx — like layout but re-mounts on every navigation. Use when you need fresh state or effects per navigation (rare).
// A typical App Router structure app/ layout.tsx ← root layout (required) page.tsx ← / route (marketing)/ about/page.tsx ← /about (route group) (dashboard)/ layout.tsx ← dashboard-specific layout home/page.tsx ← /home settings/ page.tsx ← /settings loading.tsx ← settings skeleton error.tsx ← settings error boundary
next/router — the Pages Router API. If you import this in App Router, you'll get a runtime error or undefined behaviour.
next/navigation — the App Router API. This is what you use in App Router. It provides separate hooks for each concern:
useRouter()— only navigation methods:push,replace,refresh,back,forward. Does NOT expose pathname or query.usePathname()— the current pathname as a stringuseSearchParams()— the current URL search params (must be wrapped in Suspense if used in a Server Component tree)useParams()— dynamic route params like{ slug: 'hello-world' }
// WRONG in App Router import { useRouter } from 'next/router' // CORRECT in App Router import { useRouter, usePathname, useSearchParams } from 'next/navigation' export default function MyComponent() { const router = useRouter() // push, replace, back const pathname = usePathname() // '/dashboard/settings' const params = useParams() // { id: '123' } }
next/navigation into your memory.
Route groups use (folderName) syntax. The parentheses are stripped from the URL — they only exist to organize your file system and share layouts.
The two main use cases:
1. Different layouts for different sections without URL nesting:
app/
(auth)/
layout.tsx ← centered card layout, no navbar
login/page.tsx → /login
signup/page.tsx → /signup
(dashboard)/
layout.tsx ← full sidebar layout
home/page.tsx → /home
settings/page.tsx → /settings
2. Organizing a large codebase without affecting URLs: You might group all e-commerce routes in (shop)/ and all content routes in (content)/ just for developer experience.
(marketing) group with a minimal public layout, and an (app) group with the authenticated dashboard layout. Both share the root layout but have their own middle layouts.
Parallel Routes use @slotName folder naming. They render multiple independent pages in the same layout simultaneously. Each slot can have its own loading and error states.
app/
@analytics/
page.tsx ← renders independently
@team/
page.tsx ← renders independently
layout.tsx ← receives { analytics, team, children } props
Real use case: a dashboard where a team activity feed and analytics charts load independently. If the analytics API is slow, the team feed still shows — they don't block each other.
Intercepting Routes use (..) notation. They let you show a different UI when navigating to a URL from within the app, while the direct URL still works normally.
Real use case: Instagram-style photo modal. When you click a photo in the feed, the URL changes to /photos/123 but a modal opens over the feed. If you share that link, the recipient gets a full page. This is the "modal with shareable URL" pattern.
Server Components run only on the server. Their source code never ships to the browser. They can access databases, secrets, environment variables, and the filesystem directly. They cannot use browser APIs, event handlers, useState, useEffect, or useContext.
Client Components run in the browser (and also SSR on the server for the initial HTML). They are included in the JS bundle sent to the browser. They can use all React hooks and browser APIs.
In App Router, everything is a Server Component by default. You opt into Client behaviour by adding 'use client' at the top of a file.
Decision framework — use a Client Component when you need:
- Event handlers (onClick, onChange, onSubmit)
- React hooks (useState, useEffect, useContext, useRef)
- Browser-only APIs (localStorage, window, navigator)
- Real-time updates or subscriptions
If none of those apply, keep it as a Server Component. The goal is to push 'use client' as deep (as close to leaves) in the component tree as possible.
// Server Component — no 'use client', runs on server only export default async function ProductList() { const products = await db.product.findMany() // direct DB access return ( <ul> {products.map(p => <ProductCard key={p.id} product={p} />)} </ul> ) } // Client Component — needs state for the cart button 'use client' import { useState } from 'react' export function AddToCartButton({ productId }) { const [added, setAdded] = useState(false) return <button onClick={() => setAdded(true)}>{added ? 'Added!' : 'Add to Cart'}</button> }
'use client' marks a bundle boundary. It tells Next.js: "include this file and everything it imports in the client JavaScript bundle."
It does NOT mean "only runs in the browser." Client Components still server-side render for the initial HTML — they produce HTML on the server just like Server Components. The difference is they also hydrate and run interactively in the browser after the initial load.
Think of it this way: 'use client' is a border declaration. Below this border, everything is in client-land. Server Components can import Client Components (crossing from server to client land), but Client Components cannot import Server Components.
// This whole file and its import tree goes into the browser bundle 'use client' import { HeavyChart } from './HeavyChart' // ⚠️ also goes into bundle import { useState } from 'react' export function Dashboard() { const [tab, setTab] = useState('overview') return <HeavyChart tab={tab} /> }
Context providers need useState or useReducer to hold state, which means they must be Client Components. The naive approach is to wrap your entire app in a provider:
// ❌ Naive — entire app becomes Client-land 'use client' export default function RootLayout({ children }) { return <ThemeProvider>{children}</ThemeProvider> }
The problem: once a layout or page has 'use client', all components rendered by it become Client Components too — you lose Server Component benefits for all children.
The solution: extract just the provider into a tiny Client Component file, then use it in your Server Component layout. Children passed as props to a Client Component remain Server Components:
// ✅ providers.tsx — Client Component (just the wrapper) 'use client' import { ThemeContext } from './context' import { useState } from 'react' export function Providers({ children }) { const [theme, setTheme] = useState('light') return ( <ThemeContext.Provider value={{ theme, setTheme }}> {children} // ← children are still Server Components! </ThemeContext.Provider> ) } // ✅ layout.tsx — Server Component import { Providers } from './providers' export default function RootLayout({ children }) { return <Providers>{children}</Providers> }
SSG (Static Site Generation) — HTML built at deploy time. Served from CDN. Fastest possible delivery, cheapest to host. Use for content that's the same for all users and changes rarely.
SSR (Server-Side Rendering) — HTML generated on the server for every request. Always fresh. Higher server cost, slower than CDN but data is always current. Use for personalized or highly dynamic pages.
ISR (Incremental Static Regeneration) — Starts as SSG. Regenerates in the background after a set interval. The best of both worlds for content that changes occasionally. The old page is served while the new one generates.
CSR (Client-Side Rendering) — No server HTML (or minimal shell). Data fetched in the browser. Bad for SEO, good for highly interactive, authenticated dashboards where SEO doesn't matter.
// SSG — default behaviour, fetch result cached indefinitely const res = await fetch('https://api.example.com/posts') // or explicitly: const res = await fetch(url, { cache: 'force-cache' }) // SSR — no caching, fresh on every request const res = await fetch(url, { cache: 'no-store' }) // or at the route level: export const dynamic = 'force-dynamic' // ISR — revalidate every 60 seconds const res = await fetch(url, { next: { revalidate: 60 } }) // or at the route level: export const revalidate = 60
Without streaming, SSR waits for all data to be fetched before sending any HTML to the browser. If one data source is slow, the whole page is delayed. The user sees nothing until everything is ready.
Streaming sends HTML in chunks as they become ready. The browser can start rendering and displaying content immediately — the shell of the page appears almost instantly, and slower parts stream in as they resolve.
Next.js App Router enables this via two mechanisms:
- loading.tsx — automatically wraps the page in a Suspense boundary. Shows instantly while the page's async data loads.
- Manual Suspense boundaries — wrap individual slow components to stream them independently from the rest of the page.
// page.tsx — the shell renders immediately import { Suspense } from 'react' import { ProductList } from './ProductList' import { ReviewsSkeleton } from './ReviewsSkeleton' import { Reviews } from './Reviews' export default function ProductPage() { return ( <div> <ProductList /> // fast, streams immediately <Suspense fallback={<ReviewsSkeleton />}> <Reviews /> // slow API, streams when ready </Suspense> </div> ) }
Server Actions are async functions with 'use server' at the top that run on the server but can be called directly from Client Components or HTML forms. They're the recommended mutation pattern in App Router.
// app/actions.ts 'use server' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' export async function createPost(formData: FormData) { const title = formData.get('title') as string await db.post.create({ data: { title } }) revalidatePath('/posts') // purge cache for this path redirect('/posts') // navigate after mutation } // In a Client Component 'use client' import { createPost } from './actions' export function CreatePostForm() { return <form action={createPost}> <input name="title" /> <button>Create</button> </form> }
Server Actions vs API Routes:
- Use Server Actions for mutations triggered from your own UI — form submissions, button clicks, data updates
- Use API Routes (Route Handlers) when you need an endpoint that third parties can call, or when you're building a proper REST API consumed by mobile apps or external services
- Server Actions are tightly coupled to your app; Route Handlers are a public interface
revalidatePath('/posts') — purges all cached data for a specific URL path. Simple and blunt. Use it when you want to refresh a specific page after a mutation.
revalidateTag('posts') — purges all fetch requests tagged with that label, across all paths. Surgical. Use it when the same data is displayed on multiple pages and you want to invalidate it everywhere.
// Tag your fetch requests const posts = await fetch('/api/posts', { next: { tags: ['posts'] } }) // When a post is created, invalidate anywhere 'posts' is used export async function createPost(data) { await db.post.create({ data }) revalidateTag('posts') // updates /posts, /home, /feed, anywhere } // vs revalidatePath — only updates /posts revalidatePath('/posts')
Request memoization means that if multiple Server Components in the same render pass call fetch() with the exact same URL and options, Next.js makes only one network request. All callers get the same cached result.
This changes how you architect components. Instead of prop-drilling data from a parent who fetches it once, each component can declare its own data needs:
// Before: prop drilling to avoid duplicate fetches async function Page() { const user = await getUser() // fetch once at top return <><Header user={user} /><Profile user={user} /></> } // After: each component fetches what it needs, memoization handles dedup async function Header() { const user = await getUser() // ← same URL = deduplicated return <nav>Hello {user.name}</nav> } async function Profile() { const user = await getUser() // ← same fetch, no extra network call return <div>{user.bio}</div> }
Core Web Vitals are Google's metrics for page experience. They directly affect SEO ranking and user satisfaction.
LCP — Largest Contentful Paint (target: under 2.5s): How fast the main content loads.
- Use SSG/ISR so the HTML comes from CDN, not a server doing work
- Use
next/imagewithpriorityprop on hero images — preloads them - Use
next/fontto eliminate render-blocking font requests - Use streaming to send the shell immediately
INP — Interaction to Next Paint (target: under 200ms): How responsive the page feels to interactions.
- Minimize client-side JS — Server Components reduce bundle size
- Use dynamic imports for heavy libraries not needed on load
- Avoid long-running synchronous JS in event handlers
CLS — Cumulative Layout Shift (target: under 0.1): Visual stability — does the page jump around?
- Always specify
widthandheighton images (or usefillwith a sized container) next/fonteliminates font-swap layout shift- Don't inject content above the fold after the initial render
- Reserve space for ads and dynamic content
Step 1: Measure first. Install @next/bundle-analyzer to visualise what's in your bundle and where the weight is coming from. Don't optimise blindly.
// next.config.js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true' }) module.exports = withBundleAnalyzer({}) // Run: ANALYZE=true npm run build
Step 2: Move logic to Server Components. Any utility, data transformation, or library that doesn't need to run in the browser should be in a Server Component. It won't ship to the client at all.
Step 3: Dynamic imports for heavy UI.
import dynamic from 'next/dynamic' const RichTextEditor = dynamic(() => import('./RichTextEditor'), { loading: () => <p>Loading editor...</p>, ssr: false // skip SSR for browser-only components })
Step 4: Named imports, not default imports. Import only what you use from libraries. Many libraries (like lodash) have poor tree-shaking with default imports.
// ❌ Imports entire library import _ from 'lodash' const result = _.debounce(fn, 300) // ✅ Import only what you need import debounce from 'lodash/debounce'
Route Handlers replace pages/api/ routes. Create a route.ts file inside app/. Export named async functions matching HTTP methods.
// app/api/users/[id]/route.ts import { NextRequest, NextResponse } from 'next/server' export async function GET( request: NextRequest, { params }: { params: { id: string } } ) { const user = await db.user.findUnique({ where: { id: params.id } }) if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(user) } export async function PATCH(request: NextRequest, ...) { const body = await request.json() const updated = await db.user.update({ where: { id: params.id }, data: body }) return NextResponse.json(updated) }
Key differences from Pages Router: uses Web standard Request/Response APIs instead of Node.js req/res. Can run at the edge. A folder cannot have both page.tsx and route.ts.
Middleware runs at the edge before any request is processed. It can inspect and modify incoming requests before they reach your pages or API routes.
What it CAN do:
- Redirect: send users to a different URL
- Rewrite: change what URL is actually served (URL stays the same for the user)
- Set or read request and response headers
- Read cookies (for auth checks)
- Return a response directly (for rate limiting)
What it CANNOT do:
- Access a database directly (edge runtime has no Node.js APIs)
- Use most Node.js built-ins
- Run heavy computation (it runs on every matched request)
// middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { const token = request.cookies.get('session')?.value if (!token) { return NextResponse.redirect(new URL('/login', request.url)) } return NextResponse.next() } export const config = { matcher: ['/dashboard/:path*', '/settings/:path*'] }
App Router changes the state management picture significantly. The goal is to keep as much state on the server as possible and minimize client-side state.
URL state (recommended first choice): Use search params for shareable, bookmarkable state — filters, pagination, selected tab. Server Components can read search params directly. Client Components use useSearchParams().
Server state: Data from the server lives in Server Components and the cache. Don't duplicate it in client stores — that creates sync issues. After mutations, use revalidatePath or revalidateTag to refresh it.
UI state (ephemeral): Modals open/closed, dropdown state, hover state. useState is fine here — it's transient and doesn't need to survive navigation.
Cross-component client state: When multiple Client Components share state that doesn't belong in the URL, use Zustand or Jotai. Both work well in App Router without the Redux boilerplate.
// Zustand store (client-side only) import { create } from 'zustand' const useCartStore = create((set) => ({ items: [], addItem: (item) => set((state) => ({ items: [...state.items, item] })), removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id) })) }))
There are three layers where you can enforce authentication:
1. Middleware (recommended for broad protection): Runs at the edge before anything renders. Fastest. Best for protecting entire sections of the app.
export function middleware(request: NextRequest) { const session = request.cookies.get('session') if (!session) return NextResponse.redirect(new URL('/login', request.url)) } export const config = { matcher: ['/dashboard/:path*'] }
2. Layout Server Component: Verify the session in a layout and redirect if not authenticated. Useful for role-based access within an authenticated section.
export default async function AdminLayout({ children }) { const session = await auth() if (session?.user?.role !== 'admin') redirect('/unauthorized') return children }
3. Page level: For granular page-specific checks, verify in the page itself.
Which to use when:
- Middleware for "is the user logged in at all" checks across a whole section
- Layout for "does the user have the right role/permissions for this section"
- Page for very specific data-level checks ("does this user own this resource")
Auth.js v5 was rewritten specifically for App Router. The main differences from v4:
Route Handler instead of catch-all API route:
// app/api/auth/[...nextauth]/route.ts import { handlers } from '@/auth' export const { GET, POST } = handlers
Universal auth() function:
// In Server Components import { auth } from '@/auth' const session = await auth() // In Client Components import { useSession } from 'next-auth/react' const { data: session } = useSession() // In middleware export { auth as middleware } from '@/auth'
A layered approach works best for App Router apps:
Unit tests (Vitest + React Testing Library): Test Client Components in isolation. These are the easiest to test — they're just React components.
// Testing a Client Component import { render, screen, fireEvent } from '@testing-library/react' import { AddToCartButton } from './AddToCartButton' it('shows "Added!" after clicking', async () => { render(<AddToCartButton productId="123" />) fireEvent.click(screen.getByRole('button')) await screen.findByText('Added!') })
Server Component testing: Server Components are async functions — you can test them directly:
// Testing a Server Component import { ProductList } from './ProductList' vi.mock('../db', () => ({ findProducts: vi.fn().mockResolvedValue([...]) })) it('renders products', async () => { const component = await ProductList() const { getByText } = render(component) getByText('Product Name') })
Integration / E2E tests (Playwright): Test full user flows — authentication, form submissions, navigation. This is where you catch App Router-specific issues like caching, streaming, and redirect behaviour.
cache: 'no-store'." Never leave the interviewer guessing which router you're describing.next/navigation, not next/router. Interviewers notice this immediately. It's one of the highest-signal mistakes in a live coding session.