This article may not be for everyone. To understand it, you should at least be an aspiring frontend or full-stack developer, have a basic understanding of concepts such as SSR, SSG, and ISR, and have used Next.js at least once.

Preface

Maybe I’m naturally intensely curious about the principles underlying things, and sometimes I just can’t stop asking exactly why something can’t be done a certain way. I suppose that’s why I occasionally come up with unusual ideas—and this is one of them. It first occurred to me in November 2025, and I discussed it with many friends around me at the time, but scheduling conflicts with the projects on my plate kept delaying it until around New Year 2026, when I finally built an initial version. Then I shelved it again—because I went off to build a website. The blog still isn’t entirely finished, but at least I finally have a reasonably usable place to document things, so I’d like to bring this back up and talk about it again.

First, let me make one thing clear: I’m not saying SSR is bad. I just think that, judging by the trend I’ve observed, too many people are misusing it, while the prevailing direction is to follow the crowd and avoid confronting a problem. I’ve discussed this with quite a few people, but most of their responses were uncertain. I’ve also explained my idea, yet they always worry about the various problems and edge cases it might involve—and, honestly, I don’t really want to explain the entire line of reasoning from beginning to end every time. So today I’m simply going to write it all down in detail and try to clarify some of the questions and technical principles involved.

Misused SSR

When I was just getting started with web front-end development, the first full-stack framework I encountered was probably Next.js. I admit that it is an excellent framework. Although it has some performance issues, those issues are the very real price paid for its developer experience, and that is perfectly reasonable. But as I gradually deepened my understanding of front-end concepts and knowledge, I found that it might suit me less and less—because I love writing Rust, enjoy exploring low-level systems, and have experimented with embedded systems, so I have a particular fixation on performance. That does not mean pushing everything to the limit; rather, when a way to achieve something with clearly better performance already exists and may require very little effort, why does no one do it that way? So whenever I use Next, I find myself wondering: even with a very simple page where most things are fixed and only one part needs to change, why does the entire page have to be rendered again? Why? As I understand it, even if the back end is not written in Rust, even if maximum performance is not the goal, and even if it runs on a JavaScript interpreter, the small amount of data that actually changes should incur relatively little overhead; “rendering” should be achieved at very low cost, rather than by rendering the entire page again.

It eventually became a mental hurdle for me, a question I could not avoid thinking about. Ironically, after looking back at nearly ten years of frontend development—Vue, Svelte, Solid, and their frameworks Next.js, TanStack Start, Remix, Nuxt, Sveltekit, and Soild Start—I felt that everyone seemed to have gone astray. React’s RSC, in particular, is a mistake, though its existence does have its reasons; perhaps I will discuss that in detail another time...

In my view, RSC and the whole crop of SSR frameworks are abusing render. SSG does achieve the style I want—the page is generated at build time and does not need to be rendered again when requested—but it has no truly dynamic capabilities (x). ISR, meanwhile, appears to strike a balance between SSG and SSR, offering some flexibility, but it does not fundamentally meet my expectations. I think ISR was introduced simply to compensate for SSR’s poor performance; subsequent techniques such as various CDN caching strategies are likewise all workarounds, not genuinely elegant solutions.

Of course, SSR is not inherently a mistake; it is a valuable tool that solves many real-world problems. The issue is that these frameworks have elevated SSR to the overwhelmingly default choice, even though perhaps 95% of the cases in which developers actually use it do not need it at all. Most of a page’s content is fixed, with very few truly dynamic parts, yet the entire component tree still runs on every request.

In recent years, though, React has improved somewhat: React 19.2 includes PPR, but it still incurs an expensive renderToString() at runtime, making it more of a workaround than a real solution. So I wondered: if most things are already determined at build time, why not move rendering directly to compile time?

Compile-time rendering?

Moving rendering to compile time sounds absurd, because the fundamental reason server-side rendering exists is that some values and conditions simply cannot be resolved until the exact moment they are encountered at runtime: you do not know what they are, so you cannot make a judgment. That is why I can understand most people dismissing my idea out of hand; their reaction is not unreasonable.

But I did come up with a fairly ingenious Pipeline to make this work, which is why I need to call it a Protocol. I’ve also always thought that full-stack frameworks blurring the boundary between front end and back end is incredibly foolish. (Though I have to admit that Next.js has provided an excellent developer experience in this regard in recent years, leading many beginners to believe that building a full-stack application is very easy, when in fact it also creates many security risks—but I digress.)

The same applies to fetching data inside components: I prefer clearly defined boundaries—components should be pure, and data fetching should be completely extracted. Once you accept that premise and separate pure components from data, things get interesting: you realize that data itself can actually be categorized.

I also have TypeScript to thank for the inspiration here. What a “value” actually is at compile time does not matter; what matters is its type. Whatever content needs to go into a slot can be wrapped in a type representing a finite set of possibilities, as long as it is not something like Open String (a string with infinitely many possible values). For example, when building a dashboard, conditionally rendering a section ultimately comes down to cases such as User or Admin—a set of types that can always be fully defined. In the real world, nearly every situation that requires conditional rendering or logical decisions can be reduced to a few definite possibilities.

And these possibilities can be described perfectly with a great approach: JTD!

JTD Specification

JTD (JSON Type Definition), defined in RFC 8927, has eight schema forms: Empty, Ref, Type (boolean, string, timestamp, and numeric types of various precisions), Enum, Elements, Properties, Values, and Discriminator; in addition, any schema can be marked as nullable. JTD’s advantage is that it works across languages: corresponding type mappings are available for JavaScript, Rust, Go, and almost every other language. This naturally makes it a bridge between frontend and backend, while its medium, JSON, is already the lowest common denominator between frontend and backend. In the real world, 95% of web applications do little more than display strings or numeric fields, or make decisions using boolean values—all of which fall within this scope. Once this is clear, we actually have a great deal of room to work with. Of course, JTD is not perfect; there are exceptions, such as Markdown, but we will discuss those in detail later. This is where I truly see the value of SSR.

Sentinel

Let’s start with the easy part. Since we already know the type of every dynamic value, we can do something at compile time: run the React component once with renderToString(), not using real data, but data mocked with Sentinel. What exactly does that mean?

{ user: { name: "Alice", age: 30 } }

Suppose your data looks like this; we can replace it with

{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }

These %%SEAM:...%% are sentinels, holding the position of every dynamic value. After React runs renderToString() with this sentinel data, those positions are marked in the resulting HTML. The build pipeline then converts the sentinels into slot markers formatted as HTML comments.

<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>

<!-- Slot -->
<span><!--seam:user.name--></span>

By this point, you may already have realized that these slot are “slots with type markers.” What the server-side runtime has to do now is extremely simple: obtain the real data and fill these holes with values—just plain string replacement. There is absolutely no need for renderToString(), a JavaScript runtime, or vDOM. Any language that can parse HTML comments and perform string replacement can serve as the backend—Rust, Go, and TypeScript all work. That is why this is a protocol rather than a framework.

At this point, you might wonder what to do about conditional rendering. For example, when a field is null, an entire block of content should not appear. But this does not actually require runtime JavaScript to determine; instead, the protocol can define how to handle this case.

<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->

The same goes for conditional rendering and 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, nothing more. So how are these conditional and loop blocks identified and generated at build time? The trick is actually quite clever: although we have no virtual DOM, we can render the HTML twice and compare the differences. Take conditional rendering: first render using the complete sentinel data, then set a particular nullable field to null and render again. Compare the two outputs, and the HTML that disappeared is the conditional block controlled by that field; simply wrap it in <!--seam:if:...-->. Arrays work the same way.

Maybe CTR?

Someone is bound to worry again: won’t the number of conditional-rendering combinations grow exponentially? Suppose 3–5 variables control conditional rendering, each with 10 possible values—the product certainly looks huge. In practice, though, that number is more intimidating than it is meaningful. First, we do not need to enumerate the actual contents of many string values; we only care whether a value is present nullable, so there are really just two possibilities. Second, any field genuinely used in a condition must have an enumerable type—for example, you would not use a open string for a if check, would you? Besides, even if we really did enumerate every combination, a modern CPU could probably finish in just a few ms. And all that work happens at compile time, much like compiling Rust: you pay a relatively high cost during compilation, but runtime becomes nothing more than plain string substitution. However you do the math, that is a worthwhile trade, especially since there is no particular magic involved. There is one small detail worth foreshadowing: later, I introduce a tiny embedded JavaScript execution environment for some complex derivations. That is a compromise; I will explain why later, as well as why it is theoretically unnecessary.

This is what I mean by CTR (Compile-Time Rendering). Its limitations are exactly the same as those of SSR: for example, <!--seam:path--> automatically performs HTML escaping when inserting text (&, <, >, and so on), while inserting raw HTML requires the explicit use of <!--seam:path:html-->; a missing data path becomes an empty string in text slot, while injection is skipped in attribute slot; and a each block is simply skipped if it receives anything other than an array. These behaviors are fundamentally no different from the edge cases encountered in SSR frameworks—I have merely moved SSR’s rendering step to compile time. That is all. During build time, CTR traverses the Cartesian product of every possible typed value for all conditional variables, using either arbitrary generated mock values that satisfy those types or special mock values supplied through user overrides (you normally do not need to provide the mock values required to generate every HTML variant manually, though you can; otherwise, a suitable value for each type is selected automatically). It renders every combination once, compares the results to identify the boundaries of all conditional and loop blocks, and ultimately produces a fully expanded HTML skeleton. Mathematically, as long as the data supplied at runtime conforms to these type definitions, it can always be injected into the skeleton correctly, because every possible branch path has already been exhaustively enumerated at compile time.

Consistency?

So how is consistency ensured? The answer is the JTD contract: although the frontend and backend are separate, as long as both follow the same JTD schema, their data types remain aligned and cannot become inconsistent.

But I faced an additional challenge compared with other frameworks: once the backend was freed from the JavaScript runtime, it no longer had to be tied to the frontend, so you could write it entirely in Rust or Go. In a full-stack TypeScript framework, types can directly guarantee consistency between the frontend and backend—but what about other languages? I actually used code generation here: regardless of the language used for the backend, it serves as the source of truth, and the variables and types available to the frontend are directly generated as TypeScript for it to import. This draws a clear boundary between the frontend and backend, yet they can still live in the same folder just as they would in a full-stack TypeScript framework, following single-repository conventions and calling each other directly without handwritten APIs or anything like gen OpenAPI. I use the private path /_seam/ as the framework’s default endpoint, which is configurable just as it is in Nuxt; running JTD Typed-RPC then takes care of data transport and CORS.

CTR × SSR

Then there is raw HTML slot, which brings us back to that 5% of edge cases—things like Markdown and Rich Text, whose values simply cannot be known at compile time and are extremely costly to constrain with types. Of course, you could extend the protocol, enumerate every piece of Markdown syntax, and parse it at runtime, but that would amount to rewriting a Markdown renderer, wouldn’t it? Constraining only the few minimal types is nowhere near the same amount of work as rewriting an entire rendering engine. This also returns to the philosophy I mentioned at the beginning: solve problems more elegantly and at a lower cost.

So the good news is that raw HTML slot actually allows CTR and SSR to coexist. This means you can build most of a page’s user interface with CTR at virtually no runtime cost, then render the central Markdown article with SSR. Since the backend is no longer constrained, you can either use TypeScript to import your existing SSR rendering method—or think outside the box! If your backend uses Rust, then of course you can render it with a Rust Markdown compiler. As long as it ultimately produces an HTML string that can be inserted into raw slot, it will display correctly. Using CTR does not mean you cannot use SSR; the two can coexist perfectly well.

The PPR Gap

Seen this way, CTR is essentially PPR (Partial Prerendering) in its ideal form: everything cacheable is fully rendered at compile time, with zero runtime overhead, and only the small amount of data that actually changes incurs a cost. So how does it differ from React 19.2’s PPR? The answer is that my approach is more thorough and more aggressive. PPR Even under ideal conditions, with every static part cached and only a small piece of dynamic content needing an update, React still has to rerun renderToReadableStream() even if that content is just a simple string that changed—and that cost is significant.

CTR, however, draws a clear boundary: simple types such as strings, numbers, and Booleans are replaced directly as strings; only complex types like Markdown, for which exhaustive enumeration would be prohibitively expensive, go through an actual rendering process. Moreover, rendering here no longer refers to SSR in the traditional sense—it can use a rendering method implemented in any programming language.

And RSC

Now let’s talk about RSC (React Server Components). Earlier, I said that RSC might be a mistake, because it blurred too many boundaries between the frontend and backend, introduced quite a few security risks, and led to numerous CVEs. In reality, however, this is more the fault of Next.js and other SSR approaches: users simply have no option not to use it. If you want dynamic content, you have to use SSR, even though string replacement like ours works perfectly well too. It must also be acknowledged that RSC gives the server one crucial capability: rendering arbitrary React components—in other words, executing arbitrary code.

But once you need to execute arbitrary code, exhaustively describing it with types is virtually impossible—and even if it were possible, the effort might be no less than writing a React compiler. So there is a reason for capabilities like RSC to exist. Can CTR and RSC coexist? Yes, they can, without any conflict whatsoever. But that is something to solve at the framework layer, not within the protocol itself. I haven’t implemented it yet, though it looks like I could borrow from TanStack Start: just send one additional html and one js package. In theory, it should be quite easy to implement; at the very least, the amount of work is clearly bounded.

Coming back to raw HTML slot: what it really solves are scenarios like Markdown and Rich Text — the rendered HTML is injected through dangerouslySetInnerHTML, and after hydration that region takes no part in interaction; it is "dead". This part should be kept to a minimum: if you want to give an article borders or styling, that belongs in React components, not mixed into this stretch of HTML. The drawback is that it cannot change after hydration, but it does push the cost of "SSR" under these conditions down to almost nothing, close to zero cost. Of the cases that genuinely need SSR, roughly 60% are this kind of static HTML injection; only the remaining 40% need what RSC offers — executing arbitrary components on the server.

UI Agnosticism

Finally, there is the enticing prospect of protocol agnosticism. Essentially, we only need to grasp the one key point, renderToString; whichever UI framework is used in front of it has nothing to do with the protocol. So how is this different from Astro? Rest assured, I am definitely not building yet another Astro. On the surface, my concept may resemble Astro’s islands somewhat, but the two are actually very different.

I’m not hydrating multiple runtimes on a single page. Hmm, actually, I think there are very few situations where you truly need that. Communicating component state across different technology stacks becomes extremely expensive. It’s more of a transitional solution for migrating from one stack to another when you can’t switch everything at once. Furthermore, Astro is fundamentally an MPA, whereas we can start as an MPA before hydration and become an SPA afterward, with client-side routing like Next.js that enables cross-page animations—something Astro can only dream of.

Astro and SSG

Astro’s design is really useful only in scenarios spanning multiple technology stacks. If you use it for speed but only use one framework—for example, importing React but not Vue—then I think its claimed “speed” is a false proposition. The initial screen does load entirely as HTML, but any interaction requires hydration, whose cost is downloading the entire React runtime; this is fundamentally no different from our hydration. Of course, we could later adopt the islands concept and add a shell router to enable SPA navigation across UI frameworks, but that is for a later roadmap, and at least for now I’m in no hurry.

Finally, compared with traditional SSG, I achieved the same thing as SSG: essentially, everything that can be determined at compile time is fully rendered. But ours is more dynamic, because those simple-type slot values can be replaced entirely at runtime. You can think of it as rendering SSG into a kind of entry point for an MPA: it is an MPA before hydration and becomes an SPA afterward, while retaining its "dynamic" capabilities.

Hydration mismatch?

Finally, there is the hydration mismatch I absolutely hate—and I doubt you like it either. Ultimately, though, it is simply an inconsistency in the DOM state. Traditional frameworks such as Next.js’s App Router try to wrap the entire application in React, so any markup injected by the browser on the user’s side can potentially cause a hydration error. We face the same constraints as traditional server-side rendering, but when you use TS, I also wrap a hydration div called __root around it, preventing the hydration region from extending into the metadata region and making hydration more robust. React 19 also includes built-in support for document metadata tags such as <title>, <meta>, and <link>. Even if you hydrate only a particular <div> on the page rather than the entire <html>, you can render <title>My Page</title> directly inside a component, and React will automatically hoist it into <head>.

Returning to hydration mismatches, since we already know the type of every slot at compile time, we can add a CTR equivalence check: populate fully expanded HTML with mock data derived from the type definitions, invoke the conventional renderToReadableStream() once, and then compare the two DOM trees for semantic equivalence while ignoring formatting differences. Their formatting may vary slightly, but as long as their DOM structures are strictly and completely equivalent, we really can say goodbye to hydration mismatches altogether. Why can’t traditional SSR do this? Because it performs this work only at runtime, whereas the CTR structure requires all these constraints to be enforced at compile time. Of course, we also provide any as an escape hatch, but, as with TypeScript, if you use any, you must accept the consequences: during compilation, the CLI will warn you that the open-string escape hatch any may cause a mismatch.

Serverless

Of course, we also have to consider the serverless option. In recent years, the serverless experience has become excellent—my assessment is that “it has no real downside except the cost.” CTR, however, is naturally well suited to this scenario: the work we do at runtime is lightweight and minimal, so it runs extremely quickly in a serverless environment, substantially improving both response times and overhead. The only case that cannot really be improved this way is something like rendering Markdown, but that should absolutely be optimized in the business logic—for example, by prerendering and storing the Markdown so it does not have to be rendered again for every request, just as my current website does. This is a business-layer problem, not something a framework can solve, but a framework can reduce the overhead of all the other simple logic to almost zero. Compared with traditional SSR, the overhead is no longer even in the same order of magnitude. How small is it? Roughly a few hundred microseconds to 1 ms—something traditional SSR could hardly dream of.

What if the backend uses another language? Take Cloudflare Workers, for example: many serverless platforms support WASM, so other languages can be compiled into WASM binaries and used for the backend as well. Essentially, I compile the frontend into purely static assets, much like with client-side rendering; but when these assets are paired with a private bridge, /_seam/, they can provide the same dynamic capabilities as true full-stack frameworks and, naturally, remain fully compatible with serverless platforms.

Seam and SeamJS

So maybe you're already tangled up in how Seam and SeamJS relate to each other. It's actually simple: Seam is the protocol. It defines how you mark dynamic positions with Sentinel, how those turn into slot markers, how diff detection works for conditional and loop blocks, and how the runtime injects data based on the AST. The protocol itself is language-agnostic; any backend that can parse HTML comments and do string replacement can implement it. SeamJS is the framework, one concrete implementation built on that protocol. It existing wheels like Vite, TanStack Router and TanStack Query, then fills in what they don't cover: my skeleton extraction, the injection engine, the CLI, and so on.

But honestly, SeamJS is still very bare-bones. It runs without any real issues, but it will need a lot more refinement before it is truly ready for building projects. I’m also giving more thought to architectural changes, such as abstracting the data transport channel, though I’m not sure whether I’ll stick with that direction in later versions. At the framework level, I will definitely build a full-stack TypeScript framework and a flagship framework with Rust on the backend. As for the Go implementation, I plan to remove it in a future version—I simply don’t think I have the bandwidth to maintain it.

Seam canmi21/seam

Rendering is a protocol, not a render-time computation.

TypeScript 39 stars 1 forks MIT 1 open issues Jul 3, 2026

More Than a Web Framework

So what is this useful for? In fact, it is not limited to the web. Once I abstract away the Transport channel as well, it can later be ported to desktop environments such as Electron or Tauri. Simply replace the HTTP transport pipeline with IPC communication, and the same Seam protocol continues to run. Electron apps will no longer need a loading screen at startup, and many elements can be rendered locally right away, much like server-side rendering. That is the magic of CTR!

The Cost of No JS Runtime

So why did SeamJS ultimately add a JS runtime anyway? Because while implementing this approach, I ran into a problem: CTR’s ideal imposes extremely strict constraints on initial-render data. The data must be a deterministic structure that can be fully derived; it cannot even contain any computational logic, only conditions. This is extremely demanding. From a framework developer-experience perspective, traditional React developers naturally expect to perform calculations inside components and use the resulting values directly. Strictly following CTR’s constraints to achieve a ready-to-display state would make the process extremely cumbersome and could even require writing the same component twice.

But there is actually a solution: because of the nature of the Web, the frontend can only run on JavaScript, so naturally components can only run as JS. The backend therefore needs the ability to execute JS to solve this problem; a full-stack TypeScript project can simply use its existing runtime. With Rust, you only need to embed a tiny JS runtime such as QuickJS. Note that the JavaScript runtime here implements only a standard subset of JS; it is nothing like Bun or Node, whose runtimes include complete operating-system APIs. It really is used only to infer and derive data.

This lets you keep some computational logic in the component: once the backend has the data, it runs a small piece of JavaScript to derive it into the ready-to-display state, then sends it back to the frontend for initial hydration. CTR is satisfied because it gets strictly structured derived data, while the developer experience improves because the component only needs to be written once. Of course, if you are willing to adhere strictly to pure type constraints, the backend can indeed operate with no JavaScript runtimeso that promise technically still holds. Either way, the additional JavaScript runtime is tiny: its performance overhead, memory footprint, and size are all far smaller than those of something like Node. It is essentially only 200–300 KB, yet provides tremendous flexibility.

Outlook

After all that talk, when do we actually ? Probably a long, long while yet. It really isn't that I want to ; it's that at this stage the thing has taken far too much of me hostage. If all I can do is develop it, then I basically can't write applications — whatever I try, the framework trips me up first — and lately I've mostly been writing this website and things like it anyway. So I've come around: better to build the site out to some real scale first, and then I'll know what my needs actually are. Later, when I come back to SeamJS, I'll have a TODO list; implement the items one by one and it becomes usable. And after that I can pad things out with a migration piece and a benchmark? Right, the . The idea is here. Never mind whether it's usable today; at least the prototype runs in principle. Good night 💤