Preface & Introduction — Why This Post Exists
This post is not a rebuttal.
It’s a reflection inspired by the great “Directives and the Platform Boundary” article by Tanner Linsley. I genuinely enjoyed the piece and agree with many of its points — especially around ownership, provenance, and the value of explicit APIs.
What I wanted to add is a slightly different lens: one that comes from building directive-driven tooling and from having lived through a very similar chapter in programming history, long before JavaScript had'use client'or'use server'.
Over the past year, I’ve watched countless developers treat directives in JavaScript as if they were actual language features — something built into the JavaScript spec or the runtime environment. And to be fair, at a glance, it does look that way. A string at the top of a file that magically changes how the code behaves feels authoritative.
If you haven’t been following the evolution of React Server Components and frameworks like Next.js: modern React apps now use file-level directives such as
'use client'and'use server'to control where code runs. They look like simple string literals at the top of a file, but they decide whether a component executes on the server or the client — shaping bundling, rendering and data-flow. That’s where the confusion begins.
But here’s the tension:
Directives look like language features.
Directives feel like language features.
Yet directives are not language features.
They are tooling-level signals — consumed by bundlers, compilers, and build pipelines.
This misunderstanding is not a new phenomenon.
We’ve been here before.
A Personal Flashback
The first time I saw a #pragma once in a C++ codebase as a young developer, I thought:
“Wow, the language has a keyword for this! Why doesn’t JavaScript?”
Except… it wasn’t a language keyword.
It was a compiler instruction. A directive. A hint to the preprocessor, not to the language itself.
Just like 'use strict' wasn’t “JavaScript syntax” at first — and just like 'use client' isn’t “JavaScript syntax” today.
This post explores that parallel. Because understanding the C/C++ preprocessor era is a surprisingly powerful way to understand modern JS directives — why they confuse people, and what we could learn from the past to teach them better.
Why Do Directives Feel Like Language Features?
If you show a newcomer the following code:
'use client';
export function Button() {
return <button>Click</button>;
}
and ask them what 'use client' is, most will confidently answer:
“It’s part of JavaScript.”
This isn’t ignorance. It’s pattern recognition.
Throughout their entire programming life, file-level “magic statements” have almost always been language semantics, not tooling semantics:
"use strict""use asm"
They look like syntax.
They live at the top of the file.
They change behavior.
So the brain does the obvious thing: it classifies them as language.
The TanStack article captures part of this well when it says:
“A directive at the top of a file looks authoritative. It gives the impression of being a language-level truth, not a framework hint.”
This is the crux of the confusion.
The Hidden Mechanism Makes It Worse
Directives blur boundaries because the code that reacts to them is:
- invisible
- non-local
- not traceable through imports
If I write:
import { client } from 'somewhere';
→ I can follow the import. I see who owns it. I can version it. I know what documentation to search for.
But with directives:
- there is no import
- no namespace
- no ownership reference
- no callsite to inspect
The behavior “comes from nowhere”.
Which is exactly why they feel like language.
Add Build Tools… and the Illusion Solidifies
Unlike JavaScript keywords, directives do nothing by themselves.
A runtime engine won’t throw if you mistype 'use clinet'.
A bundler or transform plugin decides what to do with it. Some ignore it, some error, some treat it as a string literal.
This leads to the second major confusion:
“If it requires a bundler to work, isn’t it part of the ecosystem platform, not the language?”
From the developer’s perspective, if code changes behavior without explicit imports, the mind defaults to:
✅ language feature
❌ library feature
And that is the trap.
Because what we call “platform” today is actually a stack of tools — not a coherent language surface. Exactly like in the C/C++ era.
A Look Back: C/C++ Macros and the Preprocessor
Long before modern JavaScript build pipelines, the C and C++ world lived through a very similar confusion. And it all started with a layer that sat before compilation: the preprocessor.
To understand today’s directives, we need to briefly revisit three pillars of that era — because they map surprisingly well to what we see today.
The C Preprocessor Was Not the Language — But It Felt Like It
Consider this:
#define PI 3.14
Many beginners encountering this for the first time assume:
“Oh, C has a special language keyword for defining constants.”
Except… #define is not part of the C language grammar.
It’s an instruction to a separate tool that runs before the compiler.
It performs text substitution — not type checking, not syntax analysis, not semantic validation.
Yet, because it lived inside .c/.h files and looked like “code”, generations of developers perceived it as the language.
Sound familiar?
A directive in JS behaves the same way:
'use server';
The JavaScript engine doesn’t know what that means.
The bundler decides what to rewrite before execution.
Both are pre-language phases that masquerade as language.
Pragmas: The Original “Framework Directives”
If macros were like today’s codegen utilities, then #pragma was the closest ancestor to modern directives:
#pragma once
This line is not part of the C++ language spec.
It’s a compiler-specific “hint” — an instruction that affects how the build system treats this file.
- No import
- No namespace or provenance
- No indication of ownership
Different compilers supported different pragmas. Some ignored them. Some produced warnings. Some behaved differently.
Replace “compiler” with “bundler” and you get 2025.
For example:
- Next.js reacts to
'use client' - Vite might ignore it
- A custom RSC bundler might interpret it differently
It’s the same fragmentation pattern seen 30 years ago.
Include Guards: The First “Invisible Build-Time Behavior”
Before #pragma once became common, C/C++ used include guards to prevent double inclusion:
#ifndef MY_HEADER_H
#define MY_HEADER_H
// header contents
#endif
This pattern:
- altered program behavior
- existed only for tooling
- had no runtime meaning
- required knowledge of the build model, not just the language
This is important:
C/C++ forced developers to learn that “code you write in a file” and “the language itself” are not the same thing.
This was a painful but transformative lesson.
We are now at the same educational moment in JavaScript.
Multi-Stage Compilation: A Familiar Pipeline
C/C++ had distinct layers:
Preprocessor → Compiler → Linker → Executable
Modern JS tooling has the same separation, just with cooler names:
Directive Scanner / Loader → AST Transforms → Bundler → Output Chunks
And just like beginners blamed “C++ the language” for preprocessor quirks, today developers blame:
- “JavaScript”
- “React”
- “the platform”
…for behaviors that actually originate from build tooling.
History is repeating itself — almost line for line.
Modern Directives Through the Lens of the Preprocessor Era
Once you see the C/C++ parallels, modern JavaScript directives suddenly make a lot more sense. They are not an evolution of JavaScript syntax — they are an evolution of compiler hints.
Let’s make the mapping explicit:
| Preprocessor Era Concept | Modern JS Equivalent |
|---|---|
#pragma |
'use client', 'use server'
|
#define macros |
code transforms / auto-generated wrappers |
#include guards |
module boundary / hydration boundaries |
| compiler-specific behavior | bundler-specific behavior |
| multi-stage builds | loader → transform → bundle pipeline |
Just like in the 90s, the surface looks deceptively simple — but the real action happens underneath.
Directives Don’t Execute — They Instruct a Tool
A key property of directives is this:
They don’t do anything themselves — they only change how something else behaves.
Here’s a breakdown of what typically happens in a directive-driven pipeline (simplified, but accurate enough):
Read file → Check for directives → Decide environment / boundary →
Run transforms → Generate client/server bundles → Output
Let’s compare that to the C pipeline:
Read file → Preprocessor expands macros + handles pragmas →
Compile → Link → Output
They are structurally identical concepts, just applied to different languages and eras.
Why the Illusion of a Language Feature Persists
Three psychological factors contribute to the confusion:
They look like syntax
Both#pragma onceand'use client'feel like reserved keywords.They live at the top of the file
Anything that shapes the entire file is often assumed to be language.They act globally and implicitly
They don’t require instantiation or import.
In other words, directives successfully exploit a linguistic illusion.
They act like a programming language — while not being one.
But Modern Directives Go Further Than C Pragmas
Here’s where things get more interesting:
C pragmas only affected compilation behavior.
Modern directives often affect execution model, code placement, bundling, and runtime boundaries.
For example, 'use server' might:
- move code into a server-only chunk
- replace calls with RPC stubs
- add serialization wrappers
- enforce data-flow constraints
This is already beyond what the C preprocessor did.
It’s closer to a macro system + compiler pass.
Which is exactly why understanding the distinction matters:
If something affects runtime execution and code placement, we should not treat it as “just a string at the top of the file”.
And yet, ironically, most misconceptions arise because that is exactly how it appears.
Build Tools Are the Real Interpreter of Directives
Just like different C compilers treated pragmas differently, modern JS tooling varies, too:
- Next.js interprets
'use client'one way - Vite + RSC implementations another
- Third-party bundlers a third way
If tomorrow another framework introduced:
'use streaming-server';
JavaScript engines wouldn’t care.
Tools would decide what it means.
And that is the mental shift we need developers to make:
Directive ≠ language
Directive = build instruction
They live in userland — not in the spec.
Before we move on, it’s worth pausing for a moment. The parallels with C and C++ are useful for understanding the shape of directives, but they can still feel abstract until you’ve seen them in practice. To bring the idea into clearer focus, let’s look at a concrete example from today’s ecosystem — one that makes the theory a bit more tangible, and shows how these compiler hints manifest in real, modern code.
📦 When Inline Server Functions Reveal the Need for Directives
I once ran into a case that perfectly exposed why some server behaviors cannot be left to runtime.
Consider an inline server function defined inside a Server Component, capturing variables from its local scope:
export default function Dashboard() { const rate = 0.27; async function save(value) { 'use server'; return value * rate; // ← captures "rate" from the component scope } return <button onClick={() => save(10)}>Save</button>; }At first glance, this feels like a normal function call. But for this to work, the bundler must do something subtle and non-negotiable:
- detect that save is a server function,
- hoist it into a server-only module,
- perform AST-level closure analysis to capture rate,
- and generate a stable, directly callable reference for the client — not a runtime-constructed wrapper.
A runtime helper cannot solve this.
By the time the code runs, the closure is gone and the intent is already lost.
The function must be transformed before the program exists, so that the client can call it as a direct function, not a proxy we stitched together too late.This is why directives fit this space so well: they tell the build tools at the moment of definition what this function truly is, giving them time to shape it accordingly.
Some decisions must happen while the code is still being woven — not after it is already alive.
The Educational Gap — and What We Can Learn From History
If you step back and look at the confusion around directives today, you’ll notice something striking:
The problem is no longer technical — it’s educational.
We’ve repeated a historical pattern:
- A tool-level construct looks like language
- Developers assume it is language
- The mental model becomes wrong
- Confusion spreads faster than documentation can correct it
This happened with the C preprocessor.
It’s happening again with JavaScript directives.
We Need to Teach the Layering — Not Just the Feature
If we teach 'use server' as:
“This makes your function run on the server.”
…we’ve already lost.
Because that sentence hides 4 separate layers:
| Layer | Responsible For |
|---|---|
| Syntax | writing a string literal |
| Loader | detecting the directive |
| Build tools | transforming / splitting code |
| Runtime | enforcing the boundary |
If developers don’t understand which layer is responsible for what, they will blame “JavaScript” for a bundler problem — just like C developers once blamed “the language” for preprocessor bugs.
What the C/C++ Community Eventually Learned
Over time, C/C++ education evolved:
Early teaching:
“Here’s how to use #define and #pragma.”
Mature teaching:
“Here’s what the preprocessor is, and why it’s separate from the language.”
After that shift, confusion dropped dramatically. No serious C++ course today teaches macros without first teaching the mental model of the compilation stages.
We need the same shift for JS directives.
Directives Aren’t Bad — They’re Powerful, If Understood
This post is not an argument against directives. They serve a purpose:
- Conveying intent declaratively
- Reducing boilerplate
- Helping tools optimize and separate code
- Giving the developer a simple switch for complex behaviors
In fact, some of the most ergonomic server/serverless features would be far more cumbersome without them.
But the price of ergonomics is clarity debt.
If we don’t teach where the magic comes from, developers misattribute the source of truth — and debugging collapses.
A Simple Mental Model to Teach (Starting Tomorrow)
I like to explain directives with a single sentence:
“A directive is a note you leave for your build tools — not for JavaScript.”
That line alone fixes 70% of misunderstandings.
Add one analogy:
“If
#pragma oncewas not C++ syntax, then'use client'is not JavaScript syntax either.”
And suddenly, people get it.
This doesn’t require more docs — it requires better framing.
A Practical Step Toward Clarity: A TypeScript Plugin for Directives
Before wrapping up, I want to share one more concrete step I took to reduce this confusion in real-world codebases. If part of the problem comes from directives looking like language yet lacking any formal structure, then giving them type-level meaning is one way to bridge the gap.
I built a small TypeScript plugin called typescript-plugin-directives that brings type safety and IntelliSense awareness to directives. It allows teams to:
- define their own directive vocabulary,
- validate them at compile time,
- get editor hints and autocomplete,
- and avoid silent typos like
'use clinet'.
The goal isn’t to “standardize” directives, but to make their intent explicit and visible to both developers and tooling — without needing a bundler to interpret them first.
You can try it here:
- npm: https://www.npmjs.com/package/typescript-plugin-directives
- GitHub: https://github.com/lazarv/typescript-plugin-directives
It’s intentionally lightweight — just a small layer to help the mental model click earlier, and to give directives a more formal shape inside TypeScript projects.
There’s a common assumption that a TypeScript plugin could “enforce” directives — that if the plugin knows about them, the system becomes safe by default.
But a TS plugin lives in the Language Service. It can provide awareness, warnings, and guidance — yet it still doesn’t run where code is actually transformed. It doesn’t participate in compilation or bundling.A plugin can help identify directive usage, but it cannot enforce their semantics.
For that, the compiler needs a signal of intent — something the type system can understand.To enable this, I expose a global
Directivetype in my plugin. Authors can usesatisfiesnot to inform the editor, but to inform the TypeScript compiler that a given string is intended to be a directive:'use server' satisfies Directive;This doesn’t change behavior or trigger any transformation.
What it does is far simpler and more fundamental:It tells the type system:
“Treat this as a directive — and validate it as such.”It aligns intent with the compiler, not with runtime, and not only with the IDE.
The actual separation of worlds — hoisting inline server functions out of a component, analyzing captured scope, generating a directly callable boundary — still belongs entirely to the build. The type system can acknowledge intent, but the bundler is the one who must act on it.
One practical caveat: today, some tooling — including Next.js — expects directives to appear as bare string literals. When written as
'use server' satisfies Directive, the directive may no longer be detected, since the literal is no longer in the exact form the framework scans for. Until this changes, this pattern won’t be picked up by Next.js.There is one more subtlety worth mentioning. This type-level intent only matters if a type-checker is actually running. Many modern toolchains — esbuild, SWC, Oxc, Bun, even most Deno and Vite setups — do not type-check at all. They simply strip types and move on. In those environments, the
Directive+satisfiesexpression becomes a silent note to the compiler that never had a chance to listen.
Closing Reflection
The article from Tanner raises a valuable conversation — one worth having early, before bad habits ossify. I don’t believe the goal should be to eliminate directives; they clearly solve real problems. But we can learn from history, and avoid the confusion that entire generations of C/C++ developers had to unlearn.
We’ve been here before.
We know how this story goes.
This time, we can skip the decade of confusion in the middle.
Teach the layers.
Teach the provenance.
Teach the mental model.
And directives will stop feeling like “secret language features” — and start feeling like the powerful, intentional compiler hints they actually are.
Top comments (0)