This article may not be suitable for everyone. To understand what it is about, you should at least be an entry-level frontend developer or full-stack developer, have a basic understanding of concepts like SSR, SSG, and ISR, and have used Next.js at least once.
Preface
It may be that I was born with a strong curiosity about underlying principles, and sometimes I persistently ask why something can't be done; I guess that's the case, so occasionally I can come up with some unique ideas: this is one of them. It first emerged back in November 2025, and I discussed it with many friends at the time, but due to scheduling conflicts with ongoing projects, it was postponed until around the New Year of 2026, when I finally built a version. Afterwards it was shelved again—because I went on to set up a website. The blog isn’t fully set up yet, but at least I have a place that can somewhat record things, so I want to bring this matter up again and talk about it.
First of all, let me make one thing clear: I am not saying SSR is bad. I just feel that, based on the trends I've observed, far too many people are abusing it, going with the flow to avoid addressing an underlying problem. I've discussed this with quite a few people, but most of the responses were rather uncertain; I've also shared my ideas, but others were always worried about various potential issues and edge cases. To be honest, I don't really want to explain my entire thought process from start to finish every single time either. So today, I decided to write it all down in detail to try and clarify some of the questions and technical principles.
Abused SSR
Back when I was just getting started with Web frontend development, the first full-stack framework I came into contact with was probably Next.js. I admit that it is a very excellent framework. Even though it has some performance issues, these performance issues are traded for tangible DX, which is perfectly understandable. But as I gradually gained a deeper understanding of frontend concepts and knowledge, I found that it might be becoming less and less suitable for me—because I really like writing Rust, love researching low-level systems, and have dabbled in embedded systems, so I have a rather peculiar obsession with performance; this obsession doesn't mean pursuing the extreme in everything, but rather: when there is clearly a way to achieve significantly better performance with perhaps very little effort, why doesn't everyone do it? So whenever I use Next, I wonder: why does an entire page have to re-render from scratch even if it's very simple, most of it is static, and only one part needs to change? Why? In my understanding, even if the backend isn't written in Rust, and even without pushing for extremes, even when running on a JavaScript interpreter, that small bit of changed data should be a relatively tiny overhead—achieving "rendering" in a very low-cost way, rather than re-rendering the whole page.
To the point where later on, it became a personal hang-up for me, a problem I couldn't help but ponder. The irony is, after looking back at the past nearly 10 years of frontend development—Vue, Svelte, Solid, and their frameworks Next.js, TanStack Start, Remix, Nuxt, Sveltekit, and Soild Start—I feel like everyone has somehow strayed off track. React's RSC in particular is a mistake, though its existence is actually justified for a reason, which I might revisit in detail later if I get the chance...
From my perspective, RSC and a whole host of SSR frameworks are actually abusing render. SSG did achieve the style I wanted—pages are generated at build time and require no re-rendering upon request—but it lacks true dynamic capabilities(x). As for ISR, while it seems to strike a balance between SSG and SSR to achieve a certain degree of flexibility, it fundamentally falls short of my expectations. I feel that the emergence of ISR was really just to make up for SSR's poor performance, and subsequent operations like various CDN caching strategies are actually just a workaround, rather than a truly elegant solution.
Of course, SSR itself isn't a mistake; it's a great thing that solves many real-world scenarios. But the problem is that these frameworks have pushed SSR into the position of being an extreme default, leading to situations where developers use SSR when, in probably 95% of cases, they don't actually need it at all. Most of the content on a page is static, with very few truly dynamic parts, yet every request still has to run through the entire component tree.
However, in recent years, React has shown some improvement. React 19.2 features PPR, but this actually still incurs expensive renderToString() at runtime, making it more of a workaround than a true solution. So I thought, since most things are already determined at build time, why not move rendering directly to compile time?
Compile-Time Rendering?
Putting rendering into the compile phase sounds like an absurd thing to do, because the core reason SSR exists is that certain values and conditions simply cannot be resolved until runtime—you don't know what they are and can't make a judgment. So I think it is not without reason that most people reject my idea right away; it's understandable.
But in fact, I came up with what could be considered a clever Pipeline to achieve this, which is also why I need to call it a kind of Protocol. Besides, I've always felt that full-stack frameworks blurring the boundary between frontend and backend is a pretty foolish thing (though I have to admit Next.js has done a great job with DX in this regard in recent years, making many beginners feel that writing a full-stack application is a very simple matter, when in reality many security risks stem from this—anyway, getting off topic).
So writing data fetching inside components is the same—I lean toward an approach with clear boundaries: components are strictly pure components, and data fetching should be completely extracted. And once you accept this premise and separate pure components from data, things get interesting: you find that data itself can actually be categorized.
Here I also want to thank TypeScript for giving me inspiration. In fact, what a so-called "value" itself actually is at compile time doesn't matter; what matters is its type. As for what content needs to be filled into a slot, as long as it is not something like Open String (a string with infinite possibilities), it can be wrapped into a type with finite possibilities. For example, when you are building a dashboard and need to conditionally render a block of content, it's nothing more than User or Admin and so on, but ultimately it is a type that can be exhaustively defined; in the real world, almost all places that require conditional rendering or logical decisions can be summarized into a few definite possibilities.
And it just so happens that these possibilities can be described with a great approach, which is JTD!
JTD Specification
In JTD (JSON Type Definition) RFC 8927, eight Schema Forms are defined: Empty, Ref, Type (boolean, string, timestamp, and numeric types of various precisions), Enum, Elements, Properties, Values, and Discriminator, plus any schema can be marked as nullable. The advantage of JTD lies in its cross-language nature: almost all languages like JavaScript, Rust, and Go have corresponding type mappings, making it a natural bridge between the frontend and backend, with JSON as its carrier—which is inherently the lowest common denominator between frontend and backend. In the real world, 95% of web applications do nothing more than display string and numeric fields or make decisions using boolean values, all of which fall within this scope. With this established, we actually have a lot of room to maneuver. Of course, JTD is not perfect, and there are exceptions like Markdown, but we will discuss that in detail later; this is actually where I truly recognize the value of SSR.
Sentinel
First, let me cover the easy part. Since we already know that every dynamic value has a type, we can do one thing at compile time: run the React component through renderToString(), but not with real data—instead, using mock data created with Sentinel. So, what does this mean?
{ user: { name: "Alice", age: 30 } }Assuming your data looks like this, we can replace it with:
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }These %%SEAM:...%% are the Sentinels, occupying the position of each dynamic value. After React runs renderToString() with this Sentinel data, these positions are marked in the output HTML. Next, the build pipeline converts these sentinels into slot markers in the form of HTML comments.
<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>
<!-- Slot -->
<span><!--seam:user.name--></span>By this point you may have already realized that these slot are "typed slots". At this stage, what the server-side runtime needs to do becomes extremely simple: take the real data and fill it into these holes, pure string replacement. No renderToString() is needed at all, no JavaScript runtime, no vDOM. Any language capable of parsing HTML comments and doing string replacement can serve as the backend -- Rust, Go, TypeScript, all work. That's why this is a protocol rather than a framework.
At this point, you might wonder: what about conditional rendering? For example, when a field is null, an entire block of content shouldn't appear. But actually, this doesn't require runtime JavaScript to determine either—instead, the protocol can define this situation.
<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->Conditional rendering is like this, and the same goes for list rendering.
<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->Even pattern matching
<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->This is the art of comments—comments were chosen here simply because they happen to be valid HTML characters, and nothing more. So how are these conditional and loop blocks determined and generated at build-time? It is actually quite a clever approach: although we don't have a vDOM, we can render the HTML twice and diff them. Take conditional rendering for instance: the first pass renders with full Sentinel data, while the second pass sets a certain nullable field to null and renders again. By comparing the two outputs, the piece of HTML that disappears is the conditional block controlled by that field, so we can simply wrap it with <!--seam:if:...-->. The same principle applies to arrays.
Maybe CTR?
Then surely someone will worry again: won't the combinations of conditional rendering suffer an exponential explosion? For instance, if 3 to 5 variables control conditional rendering, and each variable has 10 possibilities, multiplying them together indeed yields what looks like a huge number. But in reality, this number is just intimidating on the surface. First of all, for many string-typed values, we don't actually need to exhaustively enumerate their content—we only care whether it has a value or not nullable, which is effectively just 2 possibilities here. Second, fields that truly require conditional checks must have enumerable types—for example (you wouldn't use a open string to do a if check, right?). Besides, even if we really had to enumerate all combinations, running through them on a modern CPU might only take a few ms. Moreover, this weight is entirely at compile time—much like Rust compilation, where paying a heavier cost at compile time turns runtime into pure string replacement. No matter how you calculate it, it's a great trade, not to mention there's no special magic involved here. However, I can drop a small teaser here: later I actually introduced a tiny embedded JavaScript execution environment to perform some complex inferences, but this is a compromise; I'll explain the reasons later, as well as why, in theory, it could be done without.
This is what I call CTR (Compile-Time Rendering). As for its limitations, they are identical to those of SSR. For example, when <!--seam:path--> performs text insertion, it automatically performs HTML escaping (&, <, >, etc.); if you need to insert raw HTML, you must explicitly use <!--seam:path:html-->; missing data paths become empty strings in text slot and skip injection in attribute slot; and if a each block receives anything other than an array, it skips it directly. These behaviors are essentially no different from the edge cases you encounter in SSR frameworks—I have simply moved the rendering step of SSR to be executed at compile time, nothing more. Meanwhile, CTR performs a Cartesian product traversal of the type values of all conditional variables at build-time, using either an arbitrarily mocked value that matches the type or a custom mock value overridden by the user (normally, you don't need to manually write the mock values required to generate HTML variants, but you can provide them manually—otherwise it just automatically supplies a value matching your type). In this way, each combination is rendered once to diff the boundaries of all conditional blocks and loop blocks, ultimately producing a fully expanded HTML skeleton. Mathematically speaking, as long as the data passed in at runtime conforms to these type definitions, it is guaranteed to be correctly injected into this skeleton, as all possible branch paths have already been exhausted at compile time.
Consistency?
So how can consistency be guaranteed? In fact, the answer is this JTD Contract. Although the frontend and backend are separated, as long as both adhere to the same JTD schema, the data types are aligned, and there is no issue of inconsistency.
However, I face one more challenge than other frameworks: since the backend is liberated from the JavaScript runtime, it no longer needs to be tied to the frontend—you can completely use Rust or Go to write the backend. In a TypeScript full-stack framework, frontend-backend consistency can be guaranteed directly through types; but what about other languages? In fact, I used codegen here: regardless of what language the backend is written in, the backend serves as the source of truth, and the variables and types usable by the frontend are directly codegenned into TypeScript for the frontend to import. This way, although the boundary between frontend and backend is clearly drawn, they can actually live in a single folder just like a TypeScript full-stack framework, fitting Monorepo conventions and allowing direct mutual invocation without manually writing APIs or things like gen OpenAPI. In fact, I used a private path /_seam/ as the framework's default endpoint (which is configurable, just like Nuxt), so running JTD Typed-RPC resolves the issues of data transmission and CORS.
CTR x SSR
Then comes raw HTML slot, which actually brings us back to that 5% Edge Case. For things like Markdown and Rich Text, their values can't possibly be known at compile time, and constraining them with types comes at a very high cost. Of course, you could argue that we could extend the protocol, list every syntax element of Markdown, and parse it at runtime—but that workload is basically equivalent to rewriting a Markdown renderer, isn't it? In contrast, constraining only those minimum types is a completely different story from rewriting the entire rendering engine, which leads right back to the philosophy I mentioned at the very start: solving problems at a lower cost and in a more elegant way.
So the good news is that raw HTML slot can actually allow CTR and SSR to coexist. It means that most of the UI on your page is built using CTR, with almost zero runtime cost; and then for the most central Markdown article, you use SSR to render it. Since the backend has been liberated, you can choose to use TypeScript to import your original SSR rendering method, or broaden your mind! If the backend uses Rust, you can naturally use Rust's Markdown compiler to render it. In any case, as long as an HTML string is provided in the end and inserted into this raw slot, it can be displayed. Using CTR doesn't mean you can't use SSR; the two can completely coexist.
The Ideal and Reality of PPR
Looking at it this way, CTR is essentially the most ideal case of PPR (Partial Prerendering): everything cacheable is fully rendered at compile time, with zero runtime overhead — only the bit of data that actually changes carries any cost. So what's different from React 19.2's PPR? The answer is that I'm more thorough, more aggressive PPR Even in the most ideal case, where all the static parts are already cached and only a small piece of dynamic content needs updating, React still has to rerun renderToReadableStream() even if that dynamic content is just a simple string change — and that cost is not small.
And CTR draws this boundary very clearly: for simple types such as strings, numbers, and booleans, it directly performs string replacement; only complex types with an extremely high enumeration cost, like Markdown, will go through the true render process. Moreover, render here is no longer the concept of SSR in the traditional sense—it can be the rendering method of any programming language.
RSC Is Still Needed
Now let's talk about RSC (React Server Component). I mentioned earlier that RSC might be a mistake—that's because it blurred too many boundaries between front-end and back-end, brought quite a few security risks and numerous CVEs—but actually, this mistake is more because of Next.js and other SSR trends: users simply have no option not to use it. It became a situation where if you want dynamic content, you must use SSR; however, string replacement like ours is actually completely viable as well. Also, it must be admitted that RSC grants the server a crucial capability, which is rendering arbitrary React components—meaning executing arbitrary code.
However, since you are going to execute arbitrary code anyway, trying to exhaustively express it with types is virtually impossible—and even if it were possible, the workload wouldn't necessarily be smaller than writing a React compiler. So this capability of RSC exists for good reason. Can CTR and RSC coexist? The answer is yes, and they don't conflict at all. But this is something to be resolved at the framework level, not within the scope of the protocol itself. I haven't implemented it yet, but it seems we can borrow from TanStack Start: as long as we send one extra html package and one js package, the theoretical implementation is quite easy—at least it's a clear and visible scope of work.
Getting back to raw HTML slot, this solution mainly targets scenarios like Markdown and Rich Text, where the rendered HTML is injected via dangerouslySetInnerHTML, and after hydration, this area does not participate in interaction—it is "dead"; this portion of content should be minimized. If you want to add borders or styling to an article, those should be written using React components rather than mixed into this HTML. The downside of this approach is that it cannot change after hydration, but under this premise, it does drive the cost of "SSR" down to an extremely low level, close to Zero Cost. In scenarios that genuinely require SSR, roughly 60% consist of this kind of static HTML injection; only the remaining 40% need RSC's capability to execute arbitrary components on the server.
Frontend UI Agnosticism
Finally, there is the enticing protocol agnosticism: essentially, we only need to grasp the key point of renderToString, as whichever UI framework is used on the frontend actually has nothing to do with the protocol. In that case, how does this differ from Astro? Rest assured, though, I am definitely not building just another Astro. On the surface, my approach might look a bit similar to Astro's islands concept, but in reality, the differences are significant.
I didn't hydrate multiple runtimes within a single page—humm, actually, I think the scenarios where you truly need this are very limited. Component state communication across different tech stacks becomes extremely expensive; it's more like a transitional solution when migrating from one tech stack to another where you can't replace everything at once, so you bridge the transition first. Secondly, Astro is essentially an MPA, whereas we can achieve being an MPA before hydration and turning into an SPA after hydration. Just like Next.js, we have client-side routing and can do cross-page animations, which is something Astro would love to have.
Astro and SSG
Additionally, Astro's design is originally only really useful in scenarios involving multiple tech stacks. If you use it just for speed while only using one of its frameworks—for instance, introducing only React without Vue—then I think its claimed "fast speed" is a false premise. It's true that the initial screen load is entirely HTML, but the moment you want any interactivity, it requires hydration. The cost of that hydration is downloading the entire React Runtime, which is fundamentally no different from our hydration. Of course, in the future, we could also adopt the Island concept and add a shell router to achieve cross-UI-framework SPA navigation, but that's a later item on the Roadmap—at least I'm in no rush right now.
Finally, there is the comparison with traditional SSG. I've achieved the same thing as SSG—essentially rendering everything that can be determined at compile time; but we are more dynamic, because those simple-type slot values can be completely replaced at runtime. You can think of it as rendering SSG into a kind of entry point for an MPA: before hydration it's an MPA, after hydration it becomes an SPA, while retaining "dynamic" capabilities.
Perhaps saying goodbye to hydration mismatch?
Finally, there is hydration mismatch, which I really hate, and I believe you don't like it either; but at the end of the day, it is actually just an inconsistency in DOM state. Traditional frameworks like Next.js's App Router try to wrap the entire application with React, so once the browser on the user side injects any tags, there is a probability of a hydration error. Our limitations are actually the same as traditional SSR, but when you use TS, I also wrapped a hydration div called __root so that the hydration area no longer covers the metadata area, yielding more durable hydration. In addition, React 19 has built-in support for document metadata tags such as <title>, <meta>, and <link>. Even if you only hydrate a specific <div> on the page (rather than the entire <html>), rendering <title>My Page</title> directly inside a component will cause React to automatically hoist it into <head>.
Returning to hydration mismatch, given that we already know the type of each slot at compile time, we can add an extra CTR equivalence check: filling fully expanded HTML with mock data derived from type definitions, running it through traditional renderToReadableStream() once, and then comparing whether the semantic structure of their DOM trees is equivalent, without concerning ourselves with formatting (fmt) differences. There might be subtle formatting differences, but as long as the DOM structure is strictly and completely equivalent, it is actually possible to say goodbye to hydration mismatch; why can't traditional SSR do this? Because they do it at runtime, whereas the CTR structure dictates that you must enforce all these constraints at compile time. Of course, we also have an escape hatch in any, but just like with TypeScript, if you use any, you have to take responsibility for the consequences; the CLI will warn you during compilation that the any escape open string might cause a mismatch.
Exploring Serverless
Of course, the possibility of Serverless shouldn't be overlooked either. In recent years, the Serverless experience has become exceptionally good—I'd rate it as "having no drawbacks other than being expensive." However, CTR is naturally very well-suited for this scenario: what we do at runtime is extremely light and small, so running on Serverless is incredibly fast, with both response times and overhead significantly improved. The remaining part that can't be improved is scenarios like rendering Markdown, but that should be optimized entirely within the business logic—for instance, pre-rendering the Markdown and storing it so it doesn't need to be re-rendered on every request, just like my current website does. This is a business-level issue, not something the framework can solve, but the framework can help reduce the overhead of simple logic beyond that to almost zero. Compared to traditional SSR, it is no longer on the same order of magnitude in overhead. How small is it? It's roughly between a few hundred us ~ 1ms, something traditional SSR wouldn't even dare to dream of.
So what if the backend is written in another language? Take Cloudflare Workers as an example: in fact, many Serverless platforms support WASM, so other languages compiled into a WASM BIN can serve as a backend just the same. Essentially, I just compile the frontend into pure static assets, much like CSR, but these static assets, combined with a private bridge /_seam/, can achieve the same dynamic capabilities as those true full-stack frameworks, naturally making it fully compatible with Serverless as well.
SeamJS
So, are you already confused by the relationship between Seam and SeamJS? In fact, these two are very simple: Seam is a protocol. It defines how to use Sentinel to mark dynamic positions, how to convert them into slot markers, how to perform diff detection for conditional and loop blocks, and how to perform data injection based on the AST at runtime. The protocol itself is language-agnostic; any backend that can parse HTML comments and perform string replacement can implement it. SeamJS is a framework, a concrete implementation built on top of this protocol. It stitches together existing wheels like Vite, TanStack Router, and TanStack Query, and then fills in the gaps they don't cover, such as my skeleton extraction, injection engine, CLI, and the like.
But to be honest, SeamJS is still in a very basic state right now. Running it is fine, but using it to write actual projects still requires a lot of polishing. I've also been thinking more about some architectural reforms, such as the abstraction of data transport channels, though I'm not sure if I'll stick to this direction in future versions. At the framework level, I will definitely build a full-stack TypeScript framework and a flagship framework with Rust as the backend; as for the Go-related implementation, it will be removed in subsequent versions, as I feel I no longer have the energy to maintain it.
Not Just a Web Framework
So what is the point of building this? In fact, it isn't limited to the Web. For example, once I abstract away the Transport channel as well, it can subsequently be ported to Desktop environments like Electron or Tauri. As long as you replace the HTTP transport pipeline with IPC communication, it still runs the exact same Seam protocol; as a result, those Electron apps no longer need to display a loading screen on startup, and plenty of things can be rendered locally just like SSR—that's the magic of CTR!
The Cost of No JS Runtime
Why, then, did SeamJS end up adding a JS Runtime after all? Because while implementing this solution, I encountered a problem: CTR's ideal constraints on first-screen data are extremely strict. The data must be a deterministic structure that can be completely derived, without even any computation logic—only conditions. This became exceptionally demanding. As one can imagine regarding developer experience for a framework, traditional React developers naturally expect to perform some calculations inside components and use a few values directly. If one were to strictly follow CTR's constraints to achieve a ready-to-display state, the process would be extremely tedious, sometimes even resulting in writing a component twice.
However, there is actually a solution. Due to the nature of the Web, the frontend can only run on JavaScript, so naturally components can only run JS. Thus, the backend must have JS execution capabilities to solve this problem. In a full-stack TypeScript project, the runtime itself can be used to solve it; whereas in Rust, you only need to embed a tiny JS Runtime like QuickJS. Note that the JavaScript Runtime here is a standard JS subset, not at all a runtime with full operating system APIs like Bun or Node—it really is just used for derivation, for doing derive.
This way, you can still write some calculation logic inside components. After fetching data on the backend, this logic runs a small snippet of JS to derive it into a ready-to-display state, which is then sent back to the frontend for first-screen hydration. This satisfies CTR by obtaining strictly structured derived data, improves developer DX, and requires writing components only once. Of course, if you strictly adhere to pure type constraints, the backend can indeed achieve No JS Runtime—this promise actually still holds true. Regardless, this tiny added JavaScript Runtime has far lower performance overhead, memory footprint, and binary size than things like Node—around 200-300KB—yet provides immense flexibility.
Future Outlook
After all that talk, when will it actually be ready to use? Probably a long, long time from now. It's really not that I want to flake, but I feel that at this stage this thing is consuming way too much of my energy. If I can only use it for framework development, I basically can't build any actual apps—whatever I try to build gets tripped up by the framework first, not to mention I've mostly been working on building this website recently anyway. So I figured it out: I might as well build out this website to a decent scale first, so I'll know what my actual requirements are. Then maybe when I return to write SeamJS, I'll have a TODO List ready. Implementing those features one by one will make it usable, and later I could even churn out another post on migration and Benchmark? Alright, pie-in-the-sky talk complete—the concept is right here. Setting aside whether it works today, at least the concept prototype works. Good night 💤