This is part of my Building Fluentic Style series, where I’m writing down the design decisions, tradeoffs, and small surprises from building Fluentic Style.
It is one thing to make a styling library feel good in a client-side app.
It is another thing to make it feel good in Next.js App Router.
In a simple SPA-style development setup, most of the styling loop lives in one place:
component renders in the browser
Fluentic style chain resolves
atomic CSS rule is inserted
DevTools can inspect the generated rule
sourcemap points back to authored code
That is already a lot of work.
But at least the browser is the main place where the style is produced and consumed.
Next.js App Router changes the shape of the problem.
Now the page can involve:
server rendering
React Server Components
client components
streamed HTML
hydration
client-side navigation
HMR
Webpack or Turbopack
development sourcemaps
production extraction
So the hard part is not just “can Fluentic run in Next.js?”
The hard part is:
Can Fluentic keep the same CSS debugging experience when styles cross the server/client boundary?
That is what this post is about.
Docs for the Next.js integration are here:
The Goal Was Not A Special Next.js API
I did not want Fluentic to have one mental model for client apps and another one for Next.js.
This should still be normal Fluentic:
const card = style({
padding: 16,
borderRadius: 12,
}).hover({
boxShadow: '0 12px 30px rgb(15 23 42 / 0.16)',
});
export function Card() {
return <section css={card}>Hello</section>;
}
And this should still be normal Fluentic too:
const buttonStyles = {
root: style.slot({
display: 'inline-flex',
border: 0,
}),
label: style.slot({
fontWeight: 700,
}),
};
const danger = style.scope([
buttonStyles.root({
backgroundColor: '#dc2626',
}),
buttonStyles.label({
color: '#ffffff',
}),
]);
The Next.js integration should be an integration detail.
Not a styling API boundary.
That was the design goal:
client app:
css prop
atomic CSS
HMR
sourcemaps
StyleDevUtils
Next.js App Router:
same css prop
same atomic CSS model
same debug controls
server/client bridge hidden by integration
Next.js Splits The Debugging Problem In Two
For Fluentic development in Next.js, I ended up thinking about the problem in two parts.
First:
initial server-rendered HTML should already have the CSS it needs
The page should not show unstyled HTML and wait for the client runtime to catch up.
No missing style flash.
No “it becomes correct after hydration.”
Second:
after the client JavaScript loads, the browser should pick up the debug metadata
and prepare the development stylesheet with proper sourcemap behavior
That is where DevTools tracing matters.
The generated CSS should still be inspectable. The rule should still lead back to the authored style property, chain call, slot, scope, or JSX css prop.
Those are two different jobs:
server job:
make initial rendered HTML styled immediately
client job:
collect debug payloads
insert generated atomic rules into the dev stylesheet
keep sourcemaps and DevTools tracing useful
clean up temporary server-to-client attributes
A simple client-only runtime does not solve the first job.
A simple server-inserted style tag does not solve the second job.
Fluentic needs both.
Why RSC Makes This Awkward
React Server Components make this harder because the client cannot just “reach into” server component execution.
A server component can render HTML that already includes Fluentic style usage.
But the browser runtime is not running that server component.
So if the client runtime is the only place that knows how to insert development CSS, it may not know about styles that were produced on the server side.
And if the server only emits a style tag, the initial page can look right, but the client-side debug system may not have the structured rule metadata it needs for source tracing.
That creates a gap:
server knows what it rendered
client owns browser DevTools stylesheet
debug metadata needs to survive the trip
That is the real integration problem.
Some Simpler Approaches Are Not Enough
One approach is:
just inject a style tag from the server
That helps the initial render.
But App Router development does not stop at the initial render.
After the client is loaded, a user can navigate with next/link, trigger a route segment update, or receive new RSC output. That new server-produced output can contain elements with generated Fluentic class names that were not present on the first page.
So a one-time server style tag is not enough.
It can make the first HTML look correct, but it does not give the already-running client a reliable way to discover new server-produced style rules later.
Another approach is:
just let the client runtime regenerate everything
That can work in a pure client app.
But with RSC, the client does not execute the server component that produced the markup. The client may see DOM with generated class names, but it does not automatically have the original server-side style-chain execution that created those rules.
So the client cannot simply “rerun the component” to recover the missing debug CSS.
Another approach is:
write real CSS files during development
That may sound obvious, but it fights the smooth development loop I wanted. Fluentic development CSS is generated from JS/TS style chains with debug metadata, sourcemaps, element markers, rule priorities, and HMR-sensitive output. I wanted style edits to keep following the app/module update path instead of making every dev update depend on a separate CSS-file coordination layer.
So I ended up needing two cooperating paths.
One path runs during server rendering so the initial HTML arrives with the CSS it already needs.
The other path runs in the browser, watches for new server/RSC output, picks up its style metadata, and merges those rules into the development stylesheet.
That became the shape of Fluentic’s Next.js development bridge.
That covers both cases:
first HTML response
-> seed CSS prevents missing styles on initial render
later RSC/navigation output
-> DOM payload lets the running client discover and insert new rules
Part One: Seed CSS For The Initial HTML
The first job is avoiding missing styles on initial render.
In development, Fluentic collects the RSC/server-side style rules that were produced during server rendering.
Then the Next.js dev bridge can insert the changed CSS into the HTML using Next’s server insertion hook.
Conceptually:
server render
-> Fluentic style usage produces atomic rules
-> rules are collected in an RSC dev style store
-> server insertion emits a development style tag
-> initial HTML arrives with the CSS it needs
That is what prevents the initial page from waiting for the client runtime before it looks styled.
In Fluentic, this is handled by the development bridge component used in app/layout.tsx:
import { StyleDev } from '@fluentic/style/dev/rsc';
import type { ReactNode } from 'react';
import './globals.css';
export default function RootLayout(props: { children: ReactNode }) {
return (
<html lang="en">
<body>{props.children}</body>
{process.env.NODE_ENV === 'development' && (
<StyleDev enableStyleDevUtils />
)}
</html>
);
}
On the server side, StyleDev uses useServerInsertedHTML.
It checks whether the collected RSC development CSS changed. If it did, it emits a development <style> tag into the server-rendered HTML.
That style tag is not the whole debugging system.
It is the initial seed.
Its job is:
make the server-rendered page appear styled immediately
Part Two: Send Debug Payloads Through The DOM
The second job is the more interesting one.
After the client JavaScript loads, Fluentic needs the browser-side development stylesheet to know about the rules that came from the server/RSC path.
That stylesheet is where the normal Fluentic dev behavior lives:
atomic rule insertion
dedupe
priority/layers
element marker rules
debug metadata
sourcemap callsites
StyleDevUtils controls
So Fluentic needs to move structured rule information from server-rendered output to the client.
The approach is deliberately simple:
put a development payload on the rendered DOM element
let the client observer pick it up
insert the missing rules into the dev stylesheet
remove the temporary attribute
In Fluentic, the dev attribute is:
data-css-dev
The JSX/runtime path used for RSC can attach this development payload to rendered elements.
That payload can include rule information such as:
rule key
CSS text
priority
callsite
debug data
debug field
This is the important part.
The client does not only receive “some CSS string.”
It receives enough structured information to insert the rule into Fluentic’s development sheet with the same debug behavior as client-side styles.
Part Three: The Client Observer Picks It Up
On the client side, Fluentic starts a MutationObserver.
The observer watches the document for elements with the development payload attribute:
data-css-dev
It handles two cases:
new elements are added
existing elements receive or change data-css-dev
When it finds pending elements, it schedules a flush.
During the flush, it:
reads the data-css-dev payload
parses the rule list
skips rules already inserted
inserts missing atomic rules into the global dev sheet
handles element marker rules
flushes the sheet
removes data-css-dev from the element
cleans up initial seed style/link elements when no longer needed
So the development flow becomes:
server/RSC output appears in DOM
-> element carries data-css-dev
-> client observer sees it
-> Fluentic inserts missing dev rules
-> DOM payload is cleaned up
-> DevTools sees normal generated CSS with debug metadata
That is the bridge.
It turns server-rendered style usage into browser-side development stylesheet state.
Why A MutationObserver?
A useEffect that runs once is not enough.
In App Router development, the DOM can change after the initial client load.
RSC payloads can stream or update parts of the tree.
Client-side navigation with next/link can bring in new server-rendered route segments.
New server-produced elements can appear later with generated class names and matching debug payloads.
So the client bridge needs to react to the DOM changing, not just the app mounting.
A MutationObserver fits that job.
It lets Fluentic watch for new data-css-dev payloads without asking users to wrap every styled component or manually notify the runtime.
It also keeps the bridge development-only.
The payload attribute is temporary.
After the client reads it, Fluentic removes it from the DOM.
Why This Keeps HMR Smooth
One nice side effect of this design is that HMR can stay close to the normal JS/DOM development flow.
In development, Fluentic does not need every style edit to become a manually managed CSS file.
The app changes.
Next updates server/client output.
Server-rendered HTML can carry fresh development payloads.
The client observer picks them up.
The dev stylesheet updates.
That means HMR still feels like editing a normal React app:
edit TS/TSX style chain
Next refreshes the affected output
DOM receives updated dev payloads
Fluentic observer inserts updated atomic rules
DevTools sourcemaps still point back to source
There is machinery underneath, but the component author should not feel that machinery.
The point is to keep the smooth SPA-like development loop even when the app is no longer just a SPA.
Debug Metadata Survives The Server/Client Trip
This was the part I cared about most.
It is not enough for the visual CSS to work.
The generated CSS still needs to be debuggable.
The payload path preserves the information Fluentic needs:
callsite
source URL
source content when available
debug object
debug field
rule priority
rule key
That lets Fluentic insert development rules that can still point back to the authored source.
So a CSS rule produced through RSC development can still participate in the same debugging story:
generated atomic rule
-> authored property
-> chain call
-> slot/scope
-> JSX css prop marker
This is why Fluentic does not treat RSC dev CSS as “just HTML style text.”
The initial style tag makes the page look correct.
The structured payload lets the client-side debug stylesheet understand where the rules came from.
Those are different responsibilities.
Element Markers Still Work
Element markers are a good example of why the client pickup step matters.
In development, Fluentic can add a marker class to host elements that receive a css prop.
The marker rule points DevTools back to the JSX element where the css prop was attached.
In the RSC observer, element marker rules are handled specially.
If element markers are enabled, Fluentic inserts the marker rule.
If element markers are disabled, Fluentic can remove the marker class from the element.
That means the same debug control still works in Next.js App Router:
StyleDevUtils.setElementMarker.toOn();
StyleDevUtils.setElementMarker.toOff();
The user does not need a different debugging model because the element came from the server/RSC path.
StyleDevUtils Still Feels Like The Client App
Once StyleDev enables the dev utilities, the familiar console helper is available:
StyleDevUtils.info();
StyleDevUtils.usage();
StyleDevUtils.getConfig();
StyleDevUtils.setSourcemapMode.toStyle();
StyleDevUtils.setSourcemapMode.toValue();
StyleDevUtils.setElementMarker.toOn();
StyleDevUtils.setElementMarker.toOff();
StyleDevUtils.setPriorityMode.toLayer();
StyleDevUtils.setPriorityMode.toSort();
StyleDevUtils.reset();
This matters because the debugging surface should not split into:
client app debug tools
Next.js debug tools
RSC debug tools
The architecture is different.
The workflow should still feel like Fluentic.
You inspect generated CSS.
You follow sourcemaps.
You toggle element markers.
You change spread/merge trace mode.
You inspect priority output.
The server/client bridge should make that possible without becoming a second API.
The Next Plugin Wires The Build System Parts
The runtime bridge is only part of the integration.
The Next plugin also has to wire Fluentic into the build system paths Next uses internally.
User setup is small:
import stylePlugin from '@fluentic/style/plugin/nextjs';
const nextConfig = {
// your Next config
};
export default stylePlugin(nextConfig);
And TypeScript uses the Fluentic JSX runtime entry:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@fluentic/style/plugin/jsx"
}
}
Under the hood, the plugin handles:
Webpack path
Turbopack path
runtime entries
development CSS route
sourcemap sidecar
CSS cache
production CSS output
static export support
The plugin owns the Fluentic CSS entry in development and production.
So users keep normal app CSS imports in app/layout.tsx, but they do not manually import a Fluentic-generated CSS file.
That is important because Fluentic generated CSS is tied to compiler configuration, debug metadata, sourcemap behavior, runtime mode, and production output.
It should be managed by the integration.
Sourcemaps Need More Than CSS Text
At this point, the bridge can move CSS rules and debug metadata from server/RSC output to the client development stylesheet.
But sourcemaps add another problem.
For DevTools to let you click a generated CSS rule and jump back to the original TypeScript or TSX source, the sourcemap needs a source target.
One direct approach would be to include the original source content in the debug payload.
That works, but it is heavy.
If every server/RSC style payload had to carry source file contents, the HTML payload could get larger quickly. Generating sourcemaps would also become more expensive, because the bridge would be moving more than the rule metadata it actually needs for normal operation.
So Fluentic takes a lighter approach in the Next.js plugin.
In development, the Fluentic Next plugin starts a sidecar server alongside the Next.js dev server.
The generated Fluentic CSS can point its sourcemap source URLs at that sidecar server.
So instead of carrying the full original source content inside every dev payload, the rule only needs enough information to build a short source URL.
Conceptually:
generated CSS rule
-> sourcemap source URL
-> Fluentic sidecar server
-> original TS/TSX source file
That keeps the RSC/debug payload smaller.
The payload can carry a file path and debug location information, while the original source text is served on demand by the sidecar when DevTools follows the sourcemap.
That was the tradeoff I wanted:
heavy path:
put source contents inside every debug payload
lighter path:
put source location in the payload
serve original source through the sidecar when DevTools asks for it
This makes sourcemaps feel like normal DevTools sourcemaps without forcing every server-to-client style payload to carry full source files around.
Production Is A Different Mode
Development is about:
fast updates
server/client bridge
RSC payload pickup
debug metadata
sourcemaps
StyleDevUtils
Production is about:
CSS extraction
prepared JavaScript output
small runtime support
normal Next build output
For production, you run the normal Next build:
next build
The Fluentic plugin handles the production CSS path.
Fluentic style chains are compiled into extracted CSS and prepared JavaScript output.
Static export works through normal Next.js configuration too. You still pass the Next config object to stylePlugin(...); Fluentic wraps it, but output and trailingSlash are Next.js options:
import stylePlugin from '@fluentic/style/plugin/nextjs';
const nextConfig = {
output: 'export',
trailingSlash: true,
};
export default stylePlugin(nextConfig);
So the integration does not introduce a separate export mode. It follows the normal Next.js config shape.
That is separate from the development bridge.
The bridge exists for development-only problems: initial dev CSS, RSC payload pickup, HMR, debug metadata, sourcemaps, and DevTools controls.
Production has a different job: emit extracted CSS and prepared JavaScript output for the final Next build.
Runtime-Known Values Still Work
Next.js also makes it obvious why Fluentic cannot be only a static extractor.
Real app styling has values known at render time:
props
conditions
themes
scopes
component composition
runtime-selected variants
Fluentic’s production path extracts CSS from the style-chain structure and prepares style data for runtime composition.
At render time, the runtime can pick up runtime-known values, resolve selected scopes and themes, and attach the right generated classes.
That means the authoring API does not split when styles become dynamic.
It is still regular TypeScript.
You can branch, pass values around, compose scopes, and let the rendered app choose the final styling inputs.
That matters in Next.js because the app is not purely build-time and not purely client-time.
It is both.
Why This Was Hard To Get Right
The hard part was not one API call.
The hard part was keeping several promises true at the same time:
initial HTML should already be styled
client dev stylesheet should receive server/RSC rules
debug metadata should survive
sourcemaps should still point back to source
HMR should feel natural
client navigation should discover new server-produced styles
element markers should still work
Webpack and Turbopack both need integration paths
production should still extract CSS
runtime-known values should still compose
If any one of those is ignored, the integration can still “work” in a narrow sense.
But it would not be the experience I wanted.
A page could render, but flicker.
CSS could appear, but lose sourcemaps.
Initial load could work, but next/link navigation could introduce generated class names without matching dev rules.
Server styles could work, but client debugging could be vague.
Development could work, but production extraction could be a separate story.
I wanted the whole thing to feel coherent.
Why Not Stay SPA-Only?
It would have been much easier to keep Fluentic focused on client-side apps.
A lot of styling ideas look good there.
But modern React apps are not only SPAs.
Next.js App Router, RSC, server rendering, streaming, and hybrid server/client trees are normal now.
So for Fluentic, “works in the browser” was not enough.
I wanted:
works in client apps
works in Next.js App Router
works across server/client development
keeps generated CSS debuggable
keeps production extraction
keeps the same component styling model
A styling library does not need to support every framework in the universe.
But if it says it supports modern React, the Next.js story has to be real.
Not a footnote.
Where This Leaves The Integration
The key design decision was:
Do not make Next.js a separate styling model.
Make Next.js an integration path for the same styling model.
That is why the user-facing setup is small:
stylePlugin(nextConfig)
StyleDev in app/layout.tsx
same css prop
same StyleDevUtils
And the hard parts stay inside the integration:
server-inserted initial CSS
RSC dev style store
data-css-dev payloads
client MutationObserver pickup
rule dedupe
temporary DOM cleanup
element marker handling
sourcemap sidecar
Webpack/Turbopack wiring
production extraction
The component author should still feel like they are writing Fluentic.
Even in Next.js App Router.
What Comes Next
This post is the mechanism story.
There is still more I want to show later:
- the exact generated development CSS shape
- how RSC payload rules carry debug metadata
- how element markers behave in App Router
- how Webpack and Turbopack integration differ internally
- how production extracted CSS is patched into Next output
- how runtime-known values are represented in prepared style data
But the main idea is already there:
Next.js with server, client, and RSC is not the same problem as a SPA.
Fluentic should still keep CSS debuggable there.
That is the point of the Next.js integration.
Fluentic Style is still new and currently in beta. I am looking for early users to try it in real Next.js App Router, React, Preact, Solid, and component-library codebases.
Useful links:
- Docs
- Next.js Integration
- DevTools And Sourcemaps
- Debug Without Getting Lost
- GitHub
- npm: @fluentic/style
Feedback would help a lot right now, especially around Next.js App Router, RSC development, Turbopack/Webpack behavior, HMR, sourcemaps, static export, and whether the debugging workflow feels smooth in real Next apps.
Top comments (0)