Appearance
6.5.2 — Next.js and Angular
Chapter 6.5.1 described rendering strategies in the abstract. This page is the two concrete frameworks you are most likely to be asked about, and they are worth taking together because they represent opposite answers to the same question: how much should a framework decide for you?
Next.js decides the server, the routing and the caching, and leaves the rest to React. Angular decides everything — dependency injection, HTTP, forms, routing, testing, the build — and gives you one way to do each.
1. Next.js: routing is the file system
A route is a folder. What is in the folder decides what the route does.
app/
layout.tsx ← wraps everything, persists across navigation
page.tsx ← the route "/"
orders/
layout.tsx ← wraps everything under /orders
loading.tsx ← the Suspense fallback for this segment
error.tsx ← the error boundary for this segment (a Client Component)
page.tsx ← "/orders"
[id]/
page.tsx ← "/orders/123", receives params.id
(marketing)/ ← a route GROUP: organises files, adds nothing to the URL
about/page.tsx ← "/about"
api/
webhooks/route.ts ← an HTTP endpoint, not a pageFour of these are worth understanding beyond their names.
layout.tsx persists across navigation. When the user moves from /orders/1 to /orders/2, the layout is not re-rendered or remounted — its state, its scroll position and any open panel survive. That is a deliberate design: the shell stays, the content swaps. If you want a remount per navigation, template.tsx is the version that does re-mount.
loading.tsx is a Suspense boundary in disguise. Next wraps the segment's page in <Suspense fallback={<Loading />}> automatically, which means the streaming from Chapter 6.5.1 works by putting a file in a folder.
error.tsx is an error boundary in disguise, and it must be a Client Component because — as Chapter 6.4.4 established — error boundaries are classes and need client-side state. It receives the error and a reset() function to retry the segment.
Route groups in parentheses organise files without affecting URLs, which is how you give the marketing pages one layout and the application pages another without a /marketing prefix appearing in the address bar.
Server Components by default, and what makes a route dynamic
Everything under app/ is a Server Component unless it says 'use client'. So the default is: no JavaScript shipped, direct data access, as in Chapter 6.5.1.
A route is static until something makes it dynamic, and the list of things that make it dynamic is short and worth memorising, because "why is my page rebuilding on every request" always has an answer on it:
- Reading
cookies()orheaders(). - Reading
searchParamsin a page. - A
fetchmarkedcache: 'no-store'. - Explicitly exporting
dynamic = 'force-dynamic'.
All four mean the same thing: the output depends on this specific request, so it cannot be computed ahead of time. Everything else can be rendered at build time and served from a CDN.
tsx
// app/orders/[id]/page.tsx
export const revalidate = 60; // (1) incremental, 60-second window
export default async function OrderPage({ params }: { params: { id: string } }) {
// (2) Two independent fetches — start both, then await both.
const [order, history] = await Promise.all([
getOrder(params.id),
getHistory(params.id),
]);
return <OrderView order={order} history={history} />;
}
// (3) Which ids to build ahead of time. The rest are generated on first request.
export async function generateStaticParams() {
const recent = await getRecentOrderIds(1000);
return recent.map((id) => ({ id }));
}Line (1) is incremental regeneration from Chapter 6.5.1: serve from cache, regenerate in the background after 60 seconds.
Line (2) is the mistake to avoid stated positively. Two sequential awaits create a waterfall — the second request does not start until the first finishes — and on a server that is pure added latency. Promise.all starts both at once.
Line (3) builds the thousand most likely pages and leaves the long tail to be generated on demand, which is the answer to the hundred-thousand-page build problem.
On-demand invalidation is what makes this production-grade:
ts
// In a webhook handler, when the content system publishes an edit.
revalidatePath('/orders/' + id); // this one page
revalidateTag('orders'); // every fetch tagged 'orders'Tags are the more useful of the two: mark a fetch with next: { tags: ['orders'] } and one call invalidates every cached page that used it, wherever they are.
Server actions
A function that runs on the server, callable from a form or an event handler, with no API route in between:
tsx
// (1) The directive marks every export in this file as a server action.
'use server';
export async function cancelOrder(formData: FormData) {
const id = String(formData.get('orderId'));
// (2) You must authorise here. An action is a public HTTP endpoint.
const session = await requireSession();
await assertCanCancel(session.userId, id);
await db.orders.update({ where: { id }, data: { status: 'cancelled' } });
revalidateTag('orders'); // (3)
}Line (2) is not optional and is the security point that matters most. A server action compiles to a routable HTTP endpoint. The fact that you only call it from one button proves nothing — anyone can call it directly with any arguments. Treat every action exactly as you would treat an Express route: authenticate, authorise, validate the input (Chapter 9.9.3 covers the validation discipline). "It is only called from an admin page" is not access control.
Line (3) invalidates the cache so every view of that data refreshes, which is the same declare-it-changed model as Chapter 6.4.4's mutations.
The config file, and the parts that actually matter
js
// next.config.js
module.exports = {
images: {
remotePatterns: [ // (1)
{ protocol: 'https', hostname: 'cdn.example.com', pathname: '/products/**' },
],
formats: ['image/avif', 'image/webp'], // (2)
},
async redirects() { // (3)
return [{ source: '/old-checkout', destination: '/checkout', permanent: true }];
},
async rewrites() { // (4)
return [{ source: '/api/legacy/:path*', destination: 'https://old.example.com/:path*' }];
},
async headers() { // (5)
return [{ source: '/(.*)', headers: [{ key: 'X-Content-Type-Options', value: 'nosniff' }] }];
},
output: 'standalone', // (6)
};Line (1) is the one everyone meets. The image component will only optimise images from hosts you list, and the reason is not bureaucracy — without an allow-list, your image optimiser is an open proxy that anyone can point at any URL, running your CPU and your bandwidth on their behalf. remotePatterns replaced the older domains option because it can also constrain the protocol and the path, which domains could not.
Line (2) sets the negotiated formats. The optimiser serves AVIF to browsers that accept it, WebP to the rest, JPEG as a last resort — the Accept header negotiation from Chapter 5.6.1, applied automatically.
Line (3) versus line (4) is a distinction worth being exact about. A redirect changes the user's URL — the browser gets a 301 or 308 and navigates again. A rewrite does not — the URL stays the same and the server fetches the destination internally. Rewrites are how you put a new application in front of an old one path by path, which is the strangler-fig migration from Chapter 10.11.
Line (5) is where security headers belong (Chapter 6.10), applied to every response including static assets.
Line (6) makes the build emit a self-contained folder with only the files needed to run, which is what you copy into a container image. Without it you ship node_modules, and the image is several times larger.
The Image component, and why it is not just an <img>
tsx
<Image src="/hero.jpg" alt="…" width={1600} height={900} priority sizes="100vw" />It does four things by hand-writing standards: generates a srcset with several widths (Chapter 6.2.5), converts formats on demand, reserves space from the width/height so nothing shifts (Chapter 6.2.1), and lazy-loads by default.
priority disables lazy loading and adds a preload, and it belongs on exactly one image per page: the largest one above the fold. Getting that wrong in either direction is one of the most common causes of a poor Largest Contentful Paint score, which Chapter 6.7 measures.
Middleware
Code that runs before a request is handled, on every matching path:
ts
export function middleware(req: NextRequest) {
const session = req.cookies.get('session');
if (!session && req.nextUrl.pathname.startsWith('/account')) {
return NextResponse.redirect(new URL('/login', req.url));
}
}
export const config = { matcher: ['/account/:path*'] }; // scope it narrowlyIt runs in a restricted runtime — no filesystem, no native modules, and it is designed to be fast. Use it for redirects, rewrites, setting headers and cheap authentication checks. Do not put a database query or a full authorisation decision in it, both because the runtime is limited and because it runs on every matching request including static assets if you scope the matcher too widely.
2. Angular: the opposite bet
Angular is not a rendering library with an ecosystem around it. It is a complete framework where dependency injection, HTTP, forms, routing and the build all ship together and are expected to be used.
That has a real consequence for how teams work. There is one way to make an HTTP request, one way to build a form, one way to inject a service — so a developer moving between two Angular codebases recognises both immediately. The cost is that the framework is large, the vocabulary is specific to it, and using it a bit is not really an option.
Components, templates and standalone
ts
@Component({
selector: 'app-order-row', // (1) the tag name in a template
standalone: true, // (2) no NgModule needed
imports: [CommonModule, RouterLink], // (3) what this component's template may use
template: `
<tr [class.overdue]="order.isOverdue"> <!-- (4) property binding -->
<td>{{ order.reference }}</td> <!-- (5) interpolation -->
<td>{{ order.total | currency:'GBP' }}</td> <!-- (6) a pipe -->
<td><button (click)="cancel.emit(order.id)">Cancel</button></td> <!-- (7) -->
</tr>
`,
})
export class OrderRowComponent {
@Input({ required: true }) order!: Order; // (8) props in
@Output() cancel = new EventEmitter<string>(); // (9) events out
}Line (2) is the important modern change. Angular's original unit of organisation was the NgModule, a declaration block listing every component, directive and pipe. It was a persistent source of confusion — a component that existed but was not declared anywhere simply did not work, with an unhelpful error. Standalone components removed it: a component declares its own imports (line 3), and NgModule is legacy. If you are reading older Angular, this is the biggest difference you will notice.
Lines (4)–(7) are the template syntax, and the brackets are systematic once you see the rule: [x] means data flowing in, (x) means events flowing out, [(x)] means both — the last is nicknamed "banana in a box" and is just the two combined.
Lines (8) and (9) are the component's public interface: inputs in, outputs out. Compare with React, where both are props and an output is a function prop.
Dependency injection, which is the real differentiator
Angular has a hierarchical injector. A service is a class, and asking for it in a constructor is how you get it:
ts
@Injectable({ providedIn: 'root' }) // (1) one instance for the whole app
export class BasketService {
private readonly http = inject(HttpClient); // (2) the modern injection form
addItem(item: Item) { return this.http.post('/api/basket', item); }
}
@Component({ /* … */ })
export class BasketPage {
private readonly basket = inject(BasketService); // (3) same instance everywhere
}Line (1) registers the service application-wide as a singleton. Providing it on a component instead gives that component and its children their own instance — which is genuine hierarchical scoping, not a convention.
This is the Dependency Inversion and injection machinery from Chapter 9.3.9, built into the framework rather than assembled from libraries. It is the thing Angular developers miss most when they move away, and the thing React developers find heaviest when they arrive.
Change detection: zones, then signals
Angular's original model was unusual and worth understanding because you will meet it in existing code. A library called Zone.js patches every asynchronous browser API — setTimeout, addEventListener, fetch, promises — so that Angular is notified whenever any of them completes. On each notification it checks the whole component tree for changed values and updates the DOM.
It is genuinely automatic: you assign to a property and the view updates, with no setState call. It is also expensive, because "something happened somewhere" triggers a check of everything. ChangeDetectionStrategy.OnPush narrows it: check this component only when an input reference changes, an event fires inside it, or an observable it renders emits.
Signals are the current direction and they are a real improvement. A signal is a value that knows who reads it:
ts
export class BasketPage {
readonly items = signal<Item[]>([]); // (1) writable
readonly total = computed(() => // (2) derived
this.items().reduce((sum, i) => sum + i.price, 0));
add(item: Item) { this.items.update((list) => [...list, item]); } // (3)
}Line (1) creates a signal, read by calling it: items(). Line (2) derives a value that recomputes only when its inputs change. Line (3) updates it.
Because a signal tracks its readers, Angular knows exactly which views depend on it and can update only those — no tree-walking, and eventually no Zone.js at all. If you learn one modern Angular concept, learn this one.
RxJS, and where it earns its place
Angular uses RxJS for asynchronous work. An Observable is a stream of values over time, with operators to transform it — conceptually the same family as the streams in Chapter 3.8.4, applied to events.
ts
readonly results$ = this.searchControl.valueChanges.pipe(
debounceTime(300), // (1) wait for typing to pause
distinctUntilChanged(), // (2) ignore a repeat of the same value
switchMap((q) => this.api.search(q)), // (3) cancel the previous request
);Line (3) is the reason RxJS is worth the learning curve. switchMap cancels the in-flight request when a new value arrives — the exact race condition Chapter 6.4.2 solved by hand with a cancelled flag and an AbortController, expressed as one operator. Type-ahead search is three lines and is correct.
The honest counterweight: the operator surface is large, the learning curve is real, and most Angular applications use a handful of operators for HTTP and events while paying the conceptual cost of the whole library. Signals are gradually taking over the simpler state cases, and the guidance is settling on signals for state, RxJS for genuine streams of events over time.
ViewChild versus ContentChild
This is the Angular question that gets asked most often, and the distinction is simple once you have the right words.
@ViewChild reaches into your own template — markup written inside your component's template.
@ContentChild reaches into content that was projected into you — markup written by whoever used your component, arriving through <ng-content>.
ts
@Component({
selector: 'app-panel',
standalone: true,
template: `
<div class="panel">
<input #searchBox /> <!-- (1) MY template → ViewChild -->
<ng-content></ng-content> <!-- (2) the caller's markup → ContentChild -->
</div>
`,
})
export class PanelComponent implements AfterViewInit, AfterContentInit {
@ViewChild('searchBox') searchBox!: ElementRef<HTMLInputElement>; // (3)
@ContentChild(OrderRowComponent) firstRow!: OrderRowComponent; // (4)
ngAfterContentInit() { /* (5) projected content is ready first */ }
ngAfterViewInit() { this.searchBox.nativeElement.focus(); } // (6)
}Line (1) shows a template reference variable — the #searchBox syntax names an element so the template and the class can refer to it. Line (3) grabs it, which is Angular's equivalent of a React ref.
Line (4) grabs something the caller placed inside <app-panel>…</app-panel>. The component did not write that markup and cannot know what is in it until it is projected.
Lines (5) and (6) show why there are two lifecycle hooks: content is initialised before the view, because the view is built around content that must already exist. Reading a @ViewChild in ngAfterContentInit gives you undefined, and that is the bug this distinction exists to explain.
The React comparison makes it concrete: @ContentChild is asking a question about props.children, and @ViewChild is a ref into your own JSX.
HTTP interceptors
A pipeline every request passes through — the middleware idea from Chapter 9.9.1, applied on the client:
ts
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token();
const authorised = req.clone({ // (1) immutable
setHeaders: token ? { Authorization: `Bearer ${token}` } : {},
});
return next(authorised).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401) inject(Router).navigate(['/login']); // (2)
return throwError(() => err);
}),
);
};Line (1) clones because a request object is immutable — you cannot mutate it in place. Line (2) is the classic use: handle every 401 in one location instead of in every call site. Interceptors also carry logging, correlation ids, retries and loading indicators, and they compose in the order you register them.
Forms: two models, one recommendation
Template-driven forms put the rules in the template with directives, and are fine for a login box.
Reactive forms define the shape in TypeScript:
ts
readonly form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
postcode: new FormControl('', [Validators.pattern(/^[A-Z0-9 ]{5,8}$/i)]),
});The form is a typed object you can test, compose, validate conditionally and subscribe to. Reactive is the default recommendation for anything beyond trivial, for the same reason Chapter 6.4.4 preferred explicit state: the rules live somewhere you can read them all at once.
3. Choosing, honestly
Next.js when you want React, need a server, and want routing, rendering and caching decided for you. Its risk is that the caching model has moved several times across versions, so a codebase can be written against assumptions that no longer hold — read the version's own documentation rather than a blog post.
Angular when the codebase is large and long-lived, several teams touch it, and a single enforced way of doing things is worth more than flexibility. Its cost is size and specificity: the concepts do not transfer, and hiring assumes Angular experience rather than JavaScript experience.
Neither, and a plain React or Vue single-page application, when there is no server-rendering requirement and the audience is authenticated users.
The decision is rarely technical in isolation. It is about what the team already knows, how long the codebase must live, and whether search engines need to read the pages.
What the interviewer will push on
"What makes a Next.js route dynamic?" Reading cookies(), headers() or searchParams, a no-store fetch, or forcing it. All four say the output depends on this request, so it cannot be precomputed. This is the answer to "why is my page not static", which is the question actually being asked.
"How do you handle a sequential-await waterfall in a server component?" Promise.all for independent fetches. It is the same waterfall problem as the client, and easy to miss because await reads so naturally.
"Is a server action safe because only your button calls it?" No — it compiles to a public HTTP endpoint. Authenticate, authorise and validate inside the action. Getting this wrong is a genuine vulnerability and the interviewer is checking whether you treat it as an endpoint.
"Why does next.config need an image allow-list?" Without one, the optimiser is an open proxy that will resize anything for anyone at your expense. remotePatterns replaced domains because it can also constrain protocol and path.
"Redirect or rewrite?" A redirect changes the user's URL with a 301/308; a rewrite keeps it and fetches internally. Rewrites are the mechanism for migrating an old application path by path.
"ViewChild versus ContentChild?" Your own template versus content projected into you through <ng-content>. Then volunteer the lifecycle consequence — content initialises before the view, so reading a ViewChild too early gives undefined.
"What did Zone.js do, and what replaces it?" It patched every async browser API so Angular knew when to check the tree, which made updates automatic and coarse. Signals track their own readers, so Angular can update exactly the views that depend on a changed value.
"When is RxJS worth it?" For genuine streams over time — a type-ahead where switchMap cancels the superseded request in one operator. Not for everything; signals are taking over simple state, and the settled guidance is signals for state, RxJS for event streams.
One thing to volunteer: point out that Next.js layout.tsx does not remount across navigations within its segment, which is why a sidebar's scroll position and open state survive — and that template.tsx exists for when you want the opposite. It is a small detail that decides real behaviour, and it shows you have built with it rather than read the routing page.
Recall
- Next.js routing is the file system:
page·layout(persists across navigation, no remount) ·template(remounts) ·loading(aSuspenseboundary) ·error(a client-side error boundary) ·(groups)that do not appear in the URL ·route.tsfor endpoints. - A route is static until something makes it dynamic:
cookies(),headers(),searchParams, ano-storefetch, orforce-dynamic.revalidategives incremental regeneration;generateStaticParamsprebuilds the popular subset;revalidateTaginvalidates on demand from a webhook. - Independent server fetches must use
Promise.all— sequentialawaitis a waterfall. - A server action is a public HTTP endpoint. Authenticate, authorise and validate inside it; "only my button calls it" is not access control.
next.config:remotePatternsexists so the image optimiser is not an open proxy;redirectschange the user's URL,rewritesdo not;headersis where security headers belong;output: 'standalone'is what you containerise. On<Image>,prioritybelongs on exactly one above-the-fold image.- Angular ships everything and enforces one way. Standalone components replaced
NgModule. Template syntax:[in],(out),[(both)].@Input/@Outputare the component's contract. - Hierarchical dependency injection is the real differentiator —
providedIn: 'root'for a singleton, providing on a component for a scoped instance. - Change detection was Zone.js patching every async API and checking the whole tree;
OnPushnarrows it; signals track their readers so only dependent views update. - RxJS earns its place for streams over time —
switchMapcancels the superseded request, solving the type-ahead race in one operator. Settled guidance: signals for state, RxJS for event streams. @ViewChildreads your own template;@ContentChildreads projected<ng-content>. Content initialises before the view, so aViewChildread inngAfterContentInitisundefined.- Interceptors are client-side middleware: clone the immutable request, add auth, handle every 401 in one place. Reactive forms are the default for anything non-trivial.
Self-test: Which four things force a Next.js route to render per request? · Why is a server action a security boundary? · What does remotePatterns prevent? · Which Angular lifecycle hook runs first, and why does that matter for ViewChild? · What does switchMap do that a plain subscription does not?
Next: 6.6 goes underneath all of this — what a bundler is actually doing to your source files, why hot module replacement works at all, and how a single import can add 300 KB to a page.