The API as a Component Tree

Consider a conventional setup where an API endpoint returns JSON data, and a React component expects that data as props:

app.get('/api/likes/:postId', async (req, res) => {
  const postId = req.params.postId;
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  const json = {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes,
  };
  res.json(json);
});

The component needs to consume that response. The typical pattern involves a parent component that calls a data-fetching library and passes the result down:

function PostLikeButton({ postId }) {
  const [json, isLoading] = useData(`/api/likes/${postId}`);
  // ...
  return (
    <LikeButton
      totalLikeCount={json.totalLikeCount}
      isLikedByUser={json.isLikedByUser}
      friendLikes={json.friendLikes}
    />
  );
}

This works, but look closer at the shape of that API response:

app.get('/api/likes/:postId', async (req, res) => {
  const postId = req.params.postId;
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  const json = {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes,
  };
  res.json(json);
});

Notice something familiar? The key-value pairs in the response map directly to what the component expects. You are already passing props—you just never declared the destination. The data model in the API response is implicitly matching the component's prop signature.

Since the response keys align with LikeButton's prop names, the API response itself can be treated as a props object, ready to be spread directly onto the component:

app.get('/api/likes/:postId', async (req, res) => {
  const postId = req.params.postId;
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  const json = (
    <LikeButton
      totalLikeCount={post.totalLikeCount}
      isLikedByUser={post.isLikedByUser}
      friendLikes={friendLikes}
    />
  );
  res.json(json);
});

This shifts the architectural relationship. The API is no longer a passive data provider called by the client; the API becomes the parent component that renders the UI. The client simply receives a fully-formed component description. This inversion follows the Hollywood Principle: instead of components calling into the API to request data, the API calls back with the component tree.

It sounds unconventional, but before evaluating whether it's a good practice, it's worth exploring the implications of this role reversal.

Data Has Two Shapes, and They Rarely Match

Information is stored one way and displayed another. A post's likes, for instance, live in a database as individual rows, but the UI doesn't want rows—it wants a count, a boolean for "did I like this?", and a few friend names. The stored shape is the Model; the display shape is the ViewModel.

type Like = {
  createdAt: string, // Timestamp
  likedById: number, // User ID
  postId: number     // Post ID
};
[{
  createdAt: '2025-04-13T02:04:41.668Z',
  likedById: 123,
  postId: 1001
}, {
  createdAt: '2025-04-13T02:04:42.668Z',
  likedById: 456,
  postId: 1001
}, {
  createdAt: '2025-04-13T02:04:43.668Z',
  likedById: 789,
  postId: 1002
}, /* ... */]
type ViewModel = LikeButtonProps;

The gap between them is real. A ViewModel's totalLikeCount is aggregated from many Models; its isLikedByUser depends on the viewer; its friendLikes requires filtering and joining across tables. The question is never whether Models become ViewModels, but where and how that translation happens—and how it survives contact with a changing UI.

The REST Resource Trap

The conventional answer is a REST API. But REST's mental model—canonical Resources like "Post" or "Like"—sits in an uncomfortable middle ground. It's not grounded in the database (the Model), and it's not grounded in the pixels (the ViewModel).

If you keep your Resources close to the Models, the client often needs multiple roundtrips or awkward ad-hoc expansions to assemble a single screen. If you drift toward ViewModels instead, you're stuffing UI-specific fields into a shared Resource—fields that will become stale the moment a screen is redesigned.

The lifecycle is predictable:

  1. You guess a shape when the endpoint is built.
  2. A UI redesign changes what the client really needs.
  3. The endpoint is the "post" endpoint—you can't just add another one.
  4. So you either emit too much data or too little, and conventions for opt-in fields multiply.
  5. Screens start stitching responses together from multiple calls.
  6. Repeat at the next redesign.

The root cause is that REST Resources aren't grounded in either reality. They're not the database schema, and they're not the screen's requirements. A "post" object can't gracefully serve both the feed and a detail page that suddenly needs friend avatars.

Screen-First Endpoints and the BFF

A cleaner idea: stop asking for a post and ask for the data this screen renders. Replace a "get post" endpoint with a per-screen endpoint like a /screens/post-details/123. If the screen dies, the endpoint dies. If one page needs avatars in its like button, that page's response includes them—no other page pays the cost.

This doesn't require throwing away the REST API. You can wrap it in a new layer:

// You're adding new screen-specific endpoints...
app.get('/screen/post-details/:postId', async (req, res) => {
  const [post, friendLikes] = await Promise.all([
    // ...which call your existing REST API here
    fetch(`/api/post/${postId}`).then(r => r.json()),
    fetch(`/api/post/${postId}/friend-likes`).then(r => r.json()),
  ]);
  const viewModel = {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes: {
      totalLikeCount: post.totalLikeCount,
      isLikedByUser: post.isLikedByUser,
      friendLikes: friendLikes.likes.map(l => l.firstName)
    }
  };
  res.json(viewModel);
});

This is the Backend for Frontend (BFF) pattern. The BFF is the frontend's ambassador on the server. It adapts REST responses into the shapes each screen wants. Because it lives on the server, it can make serial REST calls with network-level latency instead of client roundtrips. It can also cache, persist, or even bypass HTTP entirely if it can import a data access layer directly:

import { getPost, getFriendLikes } from '@your-company/data-layer';
 
app.get('/screen/post-details/:postId', async (req, res) => {
  const postId = req.params.postId;
  const [post, friendLikes] = await Promise.all([
    // Reads from an ORM and applies business logic.
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  const viewModel = {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes: {
      totalLikeCount: post.totalLikeCount,
      isLikedByUser: post.isLikedByUser,
      friendLikes: friendLikes.likes.map(l => l.firstName)
    }
  };
  res.json(viewModel);
});

Fetching in-process means database reads can be batched, and you can fire specialized queries that were never exposed via the REST façade.

A BFF asks new questions: how do you organize so many endpoints? How do you avoid duplicating data-loading logic between them?

ViewModels as Composable, Server-Side Functions

Consider a PostList screen that renders an array of <PostDetails> components—each needing the data we would otherwise assemble for a single /screens/post-details/:postId response:

type PostListViewModel = {
  posts: PostDetailsViewModel[]
};

Rather than letting the client hit the details endpoint N times, give the list screen its own BFF endpoint. To avoid duplication, "ViewModel" becomes a reusable function:

import { getPost, getFriendLikes, getRecentPostIds } from '@your-company/data-layer';
 
async function PostDetailsViewModel({ postId }) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes: {
      totalLikeCount: post.totalLikeCount,
      isLikedByUser: post.isLikedByUser,
      friendLikes: friendLikes.likes.map(l => l.firstName)
    }
  };
}
 
app.get('/screen/post-details/:postId', async (req, res) => {
  const postId = req.params.postId;
  const viewModel = await PostDetailsViewModel({ postId });
  res.json(viewModel);
});
 
app.get('/screen/post-list', async (req, res) => {
  const postIds = await getRecentPostIds();
  const viewModel = {
    posts: await Promise.all(postIds.map(postId =>
      PostDetailsViewModel({ postId })
    ))
  };
  res.json(viewModel);
});

Zoom in further. The postLikes part of that response exists only to feed a LikeButton component. So extract a LikeButtonViewModel too:

import { getPost, getFriendLikes, getRecentPostIds } from '@your-company/data-layer';
 
async function LikeButtonViewModel({ postId }) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  return {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes.likes.map(l => l.firstName)
  };
}
 
async function PostDetailsViewModel({ postId }) {
  const [post, postLikes] = await Promise.all([
    getPost(postId), // It's fine to getPost() here again. Our data layer deduplicates calls via an in-memory cache.
    LikeButtonViewModel({ postId }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content),
    postAuthor: post.author,
    postLikes
  };
}

Now the BFF isn't one big JSON blob builder—it's a tree of ViewModels whose structure mirrors the component tree on the client. The data has its own hierarchy, but it lines up with the React tree.

This gains real power when the UI evolves. Say a like button must now also show friend avatars:

type LikeButtonProps = {
  totalLikeCount: number,
  isLikedByUser: boolean,
  friendLikes: {
    firstName: string
    avatar: string
  }[]
}

Because the only code generating friendLikes props is LikeButtonViewModel, updating it updates the JSON for every screen that renders a LikeButton. There is no separate REST resource to version, and there is a clear correspondence between the code producing a nested JSON fragment and the component consuming it.

Serve Exactly What Each Screen Needs

These ViewModel functions accept parameters, letting parent ViewModels customize what fields appear in the response.

If a Post List should only render the first paragraph of each post, the function can take a simple flag:

async function PostDetailsViewModel({
  postId,
  truncateContent
}) {
  const [post, postLikes] = await Promise.all([
    getPost(postId),
    LikeButtonViewModel({ postId }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content, {
      maxParagraphs: truncateContent ? 1 : undefined
    }),
    postAuthor: post.author,
    postLikes
  };
}
 
app.get('/screen/post-details/:postId', async (req, res) => {
  const postId = req.params.postId;
  const viewModel = await PostDetailsViewModel({
    postId,
    truncateContent: false
  });
  res.json(viewModel);
});
 
app.get('/screen/post-list', async (req, res) => {
  const postIds = await getRecentPostIds();
  const viewModel = {
    posts: await Promise.all(postIds.map(postId =>
      PostDetailsViewModel({
        postId,
        truncateContent: true
      })
    ))
  };
  res.json(viewModel);
});

A more selective option—say, friends' avatars only on the full details screen:

async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: 2 }),
  ]);
  return {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))
  };
}
async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const [post, postLikes] = await Promise.all([
    getPost(postId),
    LikeButtonViewModel({ postId, includeAvatars }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content, {
      maxParagraphs: truncateContent ? 1 : undefined
    }),
    postAuthor: post.author,
    postLikes
  };
}
 
app.get('/screen/post-details/:postId', async (req, res) => {
  const postId = req.params.postId;
  const viewModel = await PostDetailsViewModel({
    postId,
    truncateContent: false,
    includeAvatars: true
  });
  res.json(viewModel);
});
 
app.get('/screen/post-list', async (req, res) => {
  const postIds = await getRecentPostIds();
  const viewModel = {
    posts: await Promise.all(postIds.map(postId =>
      PostDetailsViewModel({
        postId,
        truncateContent: true,
        includeAvatars: false
      })
    ))
  };
  res.json(viewModel);
});

Here's the key point: the client doesn't pass an ad-hoc ?includeAvatars=true. The post-list endpoint itself decides includeAvatars: false and plumbs it down through the ViewModel tree. The client stays ignorant of server logic—it just receives the props it needs.

If the avatars list seems short at two, you can change it directly in the ViewModel function:

async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
  ]);
  return {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))
  };
}

With no canonical "post object" to complicate things, each UI can specify exactly what it needs, at any depth—screen to button. The ViewModel tree evolves in lockstep with the UI because it's the only thing serving it.

The Missing Connection

The BFF now returns the screen's JSON:

// GET /screen/post-list
{
  /* Begin screen/post-list ViewModel */
  posts: [{
    /* Begin PostDetailsViewModel */
    postTitle: "JSX Over The Wire",
    postAuthor: "Dan",
    postContent: "Suppose you have an API route that returns some data as JSON.",
    postLikes: {
      /* Begin LikeButtonViewModel */
      totalLikeCount: 8,
      isLikedByUser: false,
      friendLikes: [{
        firstName: "Alice"
      }, {
        firstName: "Bob"
      }]
      /* End LikeButtonViewModel */
    }
    /* End PostDetailsViewModel */
  }, {
    /* Begin PostDetailsViewModel */
    postTitle: "React for Two Computers",
    postAuthor: "Dan",
    postContent: "I’ve been trying to write this post at least a dozen times.",
    postLikes: {
      /* Begin LikeButtonViewModel */
      totalLikeCount: 13,
      isLikedByUser: true,
      friendLikes: [{
        firstName: "Bob"
      }]
      /* End LikeButtonViewModel */
    }
    /* End PostDetailsViewModel */
  }]
}

But the job isn't done. Some piece of client code still needs to plumb keys like postLikes into the <LikeButton> component. Two parallel hierarchies now exist—a tree of functions on the server generating JSON, and a tree of components on the client expecting props. They're aligned but not connected.

The ViewModel functions prove to be natural, reusable units of data loading, and the BFF cleanly scopes UI changes away from other screens. Still missing is the mechanism to tie the generated JSON directly to the component that consumes it.

That is the piece we need to build.

From Static Files to Server-Driven UI

Markup Before MVC

Long before JSON APIs and client-side frameworks, a website was just a directory of HTML files on a server. A homepage was a complete document, and so was each article page. Adding a shared footer meant creating a separate snippet file and pulling it in with Server-Side Includes (SSI).

<html>
  <body>
    <h1>Welcome to my blog!</h1>
    <h2>Latest posts</h2>
    <h3>
      <a href="/jsx-over-the-wire.html">
        JSX Over The Wire
      </a>
    </h3>
    <p>
      Suppose you have an API route that returns some data as JSON. [...]
    </p>
    <h3>
      <a href="/react-for-two-computers.html">
        React for Two Computers
      </a>
    </h3>
    <p>
      I’ve been trying to write this post at least a dozen times. [...]
    </p>
    ...
  </body>
</html>

To reuse logic—say, the first paragraph of a post appearing on both the index and detail pages—you could combine SSI with CGI scripts. The page would delegate to a script like post-details.cgi, which in turn queried the database. Parameters could even be passed to these includes.

This model had one defining property: the server returned all the data needed for any given screen in a single roundtrip. Different screens might share logic, and you could reuse dynamic includes to avoid duplicating it. The biggest pain point was writing it all in Bash.

PHP, XHP, and Markup as Objects

Moving to PHP improved control flow and variables, but early PHP programs still built HTML through string manipulation—a practice that led to tangled, insecure code. The broader web community responded by moving markup into templates and data fetching into controllers, the Rails-style MVC pattern.

Facebook took a different path. Its engineers argued that the real problem wasn't manipulating markup itself; it was treating markup as a plain string. Markup has structure and nesting. What was needed was a way to construct and manipulate it without corrupting its shape or allowing unsafe interpolation. That idea became XHP, where tags are not HTML strings but objects that can generate HTML.

if ($truncate) {
  $splitContent = explode("\n\n", $content);
  $firstParagraph = $splitContent[0];
  echo
    <x:frag>
      <h3><a href={"/{$postId}.php"}>{$title}</a></h3>
      <p>{$firstParagraph} [...]</p>
    </x:frag>;
} else {
  echo
    <x:frag>
      <h1>{$title}</h1>
      <p>{$content}</p>
    </x:frag>;
}

With markup as a first-class code construct, new abstractions became possible. You could define your own tags, like <ui:post-details>, and render them anywhere. Tags could render other tags, enabling pure function composition rather than an MVC framework.

XHP had a clear weakness, though: it emitted HTML on the server, making it poorly suited for interactive client experiences. Swapping innerHTML on some DOM node blew away client state—an unacceptable tradeoff for highly interactive products. That pain point eventually led to the development of JSX and React.

The Async Half-Step

Initial XHP tags received their title and content props from the calling code. Reading from a database is ideally asynchronous, while XHP tags were synchronous—until Async XHP appeared. With asynchronous rendering, a tag like <ui:post-details> could accept only a postId and load its own data.

class :ui:post-details extends :x:element {
  use XHPAsync;
 
  protected async function asyncRender(): Awaitable<XHPRoot> {
    $post = await loadPost($this->:postId);
    $title = $post->title;
    $content = $post->content;
    // ...
  }
}

This created a compelling model: self-contained components that load their own data, composed into a full screen still resolved in a single client/server roundtrip on the server side. Several details mattered for making it work well. Sibling branches of the tree should resolve in parallel, which async XHP supported. Some mechanism was needed to unblock the rest of a page when one branch was slow; Facebook's BigPipe flushed parts of the tree with explicitly designed loading states at the seams. And an ideal data layer would batch reads and share an in-memory cache across the request, keeping CPUs and I/O busy.

The system was immensely productive when apps weren't highly interactive. But rich interfaces require navigation, mutation handling, and in-place refresh without losing state. Emitting HTML alone couldn't deliver that, and React gradually won out. Yet converting interfaces lost conceptual simplicity: UI and the data it needs were pulled apart into separate codebases. GraphQL with Relay bridged the gap with significant innovations, but it never felt as direct as writing async XHP.

Beyond HTML

The XHP mental model was so effective that it didn't stay confined to the web. If an XHP tag is an object, nothing constrains it to become HTML—it can also become another representation, like JSON, or map to native iOS views.

{
  type: 'x:frag',
  props: {
    children: [{
      type: 'h1',
      props: {
        children: 'JSX Over The Wire'
      }
    },
    {
      type: 'p',
      props: {
        children: 'Suppose you have an API route that returns some data as JSON'
      }
    }]
  }
}

Those tags could be transported as JSON over the network, and a native client could read the JSON to construct its own view hierarchy. On the server, you might define your own tags that render those native primitives. The endpoint returns the entire data a screen needs in one roundtrip, where that "data" is, in fact, the native UI.

This pattern is not defeated by the argument that native apps can't rely on a backend during critical operations. The trick is to request more UI in exactly the situations where you'd otherwise make an API call: to perform an action or get new data. A fallback UI like a spinner should be available instantly, as it would be during any API call. Initial screens can even be bundled within the app's binary.

For this to work well, the set of client primitives needs careful design. Low-level system views are too granular. A good primitive palette offers highly interactive components that can implement local behaviors—like a color picker that tracks a finger's movement—while a server call handles what comes next. If the primitives are platform-agnostic, a single server codebase can assemble screens for both iOS and Android.

<nt:flexbox flex-direction="column">
  <nt:text font-size={24} font-weight={FontWeight::BOLD}>
    {$this->:title}
  </nt:text>
  <nt:text font-size={18}>
    {$this->:content}
  </nt:text>
</nt:flexbox>

SDUI: A Familiar, If Unfashionable, Idea

Returning an entire screen as JSON is neither novel nor controversial. It is essentially like HTML, only expressed with a custom design system. Any logic your API can execute—feature flags, server-only logic, data-layer reads—can run there.

Many top native apps are built precisely this way: Instagram, Airbnb, Uber, and Reddit all use in-house implementations of the pattern. Web developers are often unaware of this approach, an irony given how fundamentally "webby" the pattern is. In the native world, it goes under the name "server-driven UI" (SDUI), and the implementation is just JSON endpoints returning UI trees.

// /app/profile/123
{
  type: "Page",
  props: {
    title: "Jae's Profile",
    children: [{
      type: "Header",
      props: {
        children: [{
          type: "Avatar",
          props: {
            src: "https://example.com/avatar.jpg"
          }
        }, {
          type: "PremiumBadge",
          props: {},
        }]
      }
    }, {
      type: "Layout",
      props: {
        columns: 2,
        children: [
          // ...
        ]
      }
    }]
  }
}

The native side provides concrete implementations for the primitives—Page, Header, Avatar, PremiumBadge, Layout, and so on. At its core, the protocol distributes props from code on the server to functions on the client. Should you ever need to pass data from the server to client functions, this format might be useful.

The key insight is this: self-contained components that manage their own data, often with elegant UI, have a serious caveat—they output HTML, which is unsuitable when the interface is highly interactive and requires preserving client state between renders. This limitation made XHP ill-fated to interactivity, yet a solution exists if we abandon the constraint of rendering HTML itself.

If tags are objects, they can be delivered as JSON just for native apps. HTML imposes limits on what can be directly implemented in components. But xhp has all the benefits of keeping the logic to load that UI within its ability to return server-driven screens, since any existing web site can render data to HTML at any point without needing a custom request. SDUI's practical acceptance in the wild has made it a reliable, useful pattern—one that avoids needing sophisticated client-side computation and leaves UI distribution on servers.

Components All the Way Down

The two threads we have been pulling on — server-assembled ViewModels and markup that knows how to load its own data — turn out to be the same idea seen from opposite ends. What we really want is a system with five properties: UI split into rich components; a direct link between a component and the server code that computes its props; self-contained pieces of UI that nest without leaking data dependencies; one roundtrip per screen even with hundreds of data-loading components; and full interactivity with no full-page refreshes.

If no framework you know scores perfectly on that checklist, we can build one.

Stitching a ViewModel to Its Component

Recall the last version of LikeButtonViewModel:

async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
  ]);
  return {
    totalLikeCount: post.totalLikeCount,
    isLikedByUser: post.isLikedByUser,
    friendLikes: friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))
  };
}

That function is a slice of backend logic that prepares props for LikeButton:

{
  totalLikeCount: 8,
  isLikedByUser: false,
  friendLikes: [{
    firstName: 'Alice',
    avatar: 'https://example.com/alice.jpg'
  }, {
    firstName: 'Bob',
    avatar: 'https://example.com/bob.jpg'
  }]
}

The missing piece was a mechanism to hand that JSON to the component. Our earlier ViewModel returned plain data:

function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  // ...
}

What if, instead, it returned a tag that names its consumer?

async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
  ]);
  return (
    <LikeButton
      totalLikeCount={post.totalLikeCount}
      isLikedByUser={post.isLikedByUser}
      friendLikes={friendLikes.likes.map(l => ({
        firstName: l.firstName,
        avatar: includeAvatars ? l.avatar : null,
      }))}
    />
  );
}

JSX compiles to a JSON tree, so this is nearly the same payload — but now it declares the receiving component:

{
  type: "LikeButton",
  props: {
    totalLikeCount: 8,
    isLikedByUser: false,
    friendLikes: [{
      firstName: 'Alice',
      avatar: 'https://example.com/alice.jpg'
    }, {
      firstName: 'Bob',
      avatar: 'https://example.com/bob.jpg'
    }]
  }
}

React on the client can then pass those props straight to LikeButton:

function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  // ...
}

That closes the loop. The producer of props and the consumer of props are a Ctrl+Click apart, and since JSX is typechecked, mismatches surface at build time.

The full picture:

async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
  const [post, friendLikes] = await Promise.all([
    getPost(postId),
    getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
  ]);
  return (
    <LikeButton
      totalLikeCount={post.totalLikeCount}
      isLikedByUser={post.isLikedByUser}
      friendLikes={friendLikes.likes.map(l => ({
        firstName: l.firstName,
        avatar: includeAvatars ? l.avatar : null,
      }))}
    />
  );
}
function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  let buttonText = 'Like';
  if (totalLikeCount > 0) {
    // e.g. "Liked by You, Alice, and 13 others"
    buttonText = formatLikeText(totalLikeCount, isLikedByUser, friendLikes);
  }
  return (
    <button className={isLikedByUser ? 'liked' : ''}>
      {buttonText}
    </button>
  );
}

The ViewModel is now essentially an Async XHP tag that hands data to a client-side primitive. Together they form a self-contained unit of UI that knows how to fetch what it renders.

Revisiting Composition

Suppose a PostDetails component exists that consumes the JSON produced by PostDetailsViewModel:

async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const [post, postLikes] = await Promise.all([
    getPost(postId),
    LikeButtonViewModel({ postId, includeAvatars }),
  ]);
  return {
    postTitle: post.title,
    postContent: parseMarkdown(post.content, {
      maxParagraphs: truncateContent ? 1 : undefined
    }),
    postAuthor: post.author,
    postLikes
  };
}
function PostDetails({
  postTitle,
  postContent,
  postAuthor,
  postLikes,
}) {
  // ...
}

Rather than Promise.all plumbing, we change the ViewModel to return a PostDetails tag:

async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const [post, postLikes] = await Promise.all([
    getPost(postId),
    LikeButtonViewModel({ postId, includeAvatars }),
  ]);
  return (
    <PostDetails
      postTitle={post.title}
      postContent={parseMarkdown(post.content, {
        maxParagraphs: truncateContent ? 1 : undefined
      })}
      postAuthor={post.author}
      postLikes={postLikes}
    />
  );
}

The JSON now wraps the payload in a PostDetails element:

{
  type: "PostDetails",
  props: {
    postTitle: "JSX Over The Wire",
    postAuthor: "Dan",
    postContent: "Suppose you have an API route that returns some data as JSON.",
    postLikes: {
      type: "LikeButton",
      props: {
        totalLikeCount: 8,
        isLikedByUser: false,
        friendLikes: [{
          firstName: "Alice"
        }, {
          firstName: "Bob"
        }]
      }
    }
  }
}

On the client, React forwards those props to PostDetails:

function PostDetails({
  postTitle,
  postContent,
  postAuthor,
  postLikes,
}) {
  return (
    <article>
      <h1>{postTitle}</h1>
      <div dangerouslySetInnerHTML={{ __html: postContent }} />
      <p>by {postAuthor.name}</p>
      <section>
        {postLikes}
      </section>
    </article>
  );
}

Notice that postLikes was already a fully configured <LikeButton> — we obtained it by invoking LikeButtonViewModel:

<section>
  {postLikes}
</section>
{
  type: "PostDetails",
  props: {
    // ...
    postLikes: {
      type: "LikeButton",
      props: {
        totalLikeCount: 8,
        // ...
      }
    }
  }
}
async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const [post, postLikes] = await Promise.all([
    getPost(postId),
    LikeButtonViewModel({ postId, includeAvatars }),
  ]);
  // ...

Having ViewModels invoke each other manually inside Promise.all grows tedious. So adopt a convention: a ViewModel may embed another ViewModel by returning its JSX tag. That cleans the composition considerably:

async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const post = await getPost(postId);
  return (
    <PostDetails
      postTitle={post.title}
      postContent={parseMarkdown(post.content, {
        maxParagraphs: truncateContent ? 1 : undefined
      })}
      postAuthor={post.author}
      postLikes={
        <LikeButtonViewModel
          postId={postId}
          includeAvatars={includeAvatars}
        />
      }}
    />
  );
}

Calling PostDetailsViewModel now yields “unfinished” JSON:

{
  type: "PostDetails", // ✅ This is a component on the client
  props: {
    postTitle: "JSX Over The Wire",
    // ...
    postLikes: {
      type: LikeButtonViewModel, // 🟡 We haven't run this ViewModel yet
      props: {
        postId: "jsx-over-the-wire",
        includeAvatars: false,
      }
    }
  }
}

The serialization layer sees the embedded ViewModel, runs it, and fills in its contribution:

{
  type: "PostDetails", // ✅ This is a component on the client
  props: {
    postTitle: "JSX Over The Wire",
    // ...
    postLikes: {
      type: "LikeButton", // ✅ This is a component on the client
      props: {
        totalLikeCount: 8,
        // ...
      }
    }
  }
}

ViewModels recursively unfold, each supplying its slice of the JSON, much like XHP tags recursively render. The client receives a complete React component tree:

<PostDetails
  postTitle="JSX Over The Wire"
  // ...
  postLikes={
    <LikeButton
      totalLikeCount={8}
      // ...
    />
  }
/>

Renaming postLikes to children lets us nest the inner ViewModel as a JSX child. Data then flows down through the whole structure:

async function PostDetailsViewModel({
  postId,
  truncateContent,
  includeAvatars
}) {
  const post = await getPost(postId);
  return (
    <PostDetails
      postTitle={post.title}
      postContent={parseMarkdown(post.content, {
        maxParagraphs: truncateContent ? 1 : undefined
      })}
      postAuthor={post.author}
    >
      <LikeButtonViewModel
        postId={postId}
        includeAvatars={includeAvatars}
      />
    </PostDetails>
  );
}
 
async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
const [post, friendLikes] = await Promise.all([
  getPost(postId),
  getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
]);
return (
  <LikeButton
    totalLikeCount={post.totalLikeCount}
    isLikedByUser={post.isLikedByUser}
    friendLikes={friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))}
  />
);

By the time the JSON is serialized, getPost, parseMarkdown, and getFriendLikes have all run. The response contains data for the entire screen in a single roundtrip:

{
  type: "PostDetails", // ✅ This is a component on the client
  props: {
    postTitle: "JSX Over The Wire",
    // ...
    children: {
      type: "LikeButton", // ✅ This is a component on the client
      props: {
        totalLikeCount: 8,
        // ...
      }
    }
  }
}
function PostDetails({
  postTitle,
  postContent,
  postAuthor,
  children,
}) {
  return (
    <article>
      <h1>{postTitle}</h1>
      <div dangerouslySetInnerHTML={{ __html: postContent }} />
      <p>by {postAuthor.name}</p>
      <section>
        {children}
      </section>
    </article>
  );
}
 
function LikeButton({ totalLikeCount, isLikedByUser, friendLikes }) {
  // ...
}

On the client, everything arrives precomputed. PostDetails receives a children prop that is itself the <LikeButton> tag with its props already set. That is why, client-side, all props are “already there.”

This is a way to compose tags across the client-server boundary where server parts can wrap client parts and vice versa, with all server data loading coalesced into one request.

Routing in the ViewModel World

If ViewModels are tags, separate Express routes per screen become unnecessary. Instead, route each path to a ViewModel-building entry point:

app.get('/*', async (req, res) => {
  const url = req.url;
  const json = await toJSON(<RouterViewModel url={url} />); // Evaluate JSX
  res.json(json);
});

A RouterViewModel matches paths to screens:

function RouterViewModel({ url }) {
  let route;
  if (matchRoute(url, '/screen/post-details/:postId')) {
    const { postId } = parseRoute(url, '/screen/post-details/:postId');
    route = <PostDetailsRouteViewModel postId={postId} />;
  } else if (matchRoute(url, '/screen/post-list')) {
    route = <PostListRouteViewModel />;
  }
  return route;
}

Each route itself becomes a ViewModel:

function PostDetailsRouteViewModel({ postId }) {
  return <PostDetailsViewModel postId={postId} />
}
 
async function PostListRouteViewModel() {
  const postIds = await getRecentPostIds();
  return (
    <>
      {postIds.map(postId =>
        <PostDetailsViewModel key={postId} postId={postId} />
      )}
    </>
  );
}

On the server it is ViewModels all the way down. Moving routing into the ViewModel layer lets RouterViewModel wrap its output in a client-side <Router> that can request fresh JSON on navigation:

function RouterViewModel({ url }) {
  let route;
  if (matchRoute(url, '/screen/post-details/:postId')) {
    const { postId } = parseRoute(url, '/screen/post-details/:postId');
    route = <PostDetailsRouteViewModel postId={postId} />;
  } else if (matchRoute(url, '/screen/post-list')) {
    route = <PostListRouteViewModel />;
  }
  return (
    <Router>
      {route}
    </Router>
  );
}
function Router({ children }) {
  const [tree, setTree] = useState(children);
  // ... maybe add some logic here later ...
  return tree;
}

A granular router could split the path, prepare each segment’s ViewModel in parallel, and refetch only the changed segment — without re-requesting the whole page. That logic belongs in a framework, not in application code.

The Mechanism

We are describing React Server Components. The ViewModels are Server Components; the Components are Client Components. The two share the name because they no longer play different roles — both produce UI structure, and in practice any Client Component could equally well run on the server.

The unsolved piece is how the two module systems connect. When you import from a module marked with 'use client', you receive not the real component, but a reference that describes how to load it:

import { LikeButton } from './LikeButton';
 
console.log(LikeButton);
// "src/LikeButton.js#LikeButton"
 
async function LikeButtonViewModel({
  postId,
  includeAvatars
}) {
const [post, friendLikes] = await Promise.all([
  getPost(postId),
  getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
]);
return (
  <LikeButton
    totalLikeCount={post.totalLikeCount}
    isLikedByUser={post.isLikedByUser}
    friendLikes={friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))}
  />
);
'use client';
 
export function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  let buttonText = 'Like';
  if (totalLikeCount > 0) {
    // e.g. "Liked by You, Alice, and 13 others"
    buttonText = formatLikeText(totalLikeCount, isLikedByUser, friendLikes);
  }
  return (
    <button className={isLikedByUser ? 'liked' : ''}>
      {buttonText}
    </button>
  );
}

The generated JSON therefore carries a loading instruction for LikeButton:

{
  type: "src/LikeButton.js#LikeButton", // ✅ This is a Client Component
  props: {
    totalLikeCount: 8,
    // ...
  }
}

React fetches that reference as a script tag or reads it from the bundler cache. Because the format is bundler-specific, React Server Components require a bundler integration — Parcel ships one that is framework-agnostic.

Emitting JSON rather than HTML is essential. It allows the server tree to be refetched in place without losing client state — React simply applies new props to existing components. It allows targeting non-web platforms. And it does not preclude HTML: executing all Client Components inside the JSON produces HTML for the first render. Turning JSON into HTML is easy; the reverse is not.

What This Gets Us

  • A component and the server code preparing its props stay directly linked — “Find All References” shows every place data flows into a given component.
  • Self-contained UI units can fetch their own data, but all that fetching is coalesced into one roundtrip.
  • Refetches deliver fresh props without blowing away client state.
  • The JSON can optionally be rendered to HTML for first paint.
  • Modular authoring, serialized execution — the code is decomposed, the work is not.

Final Shape

The terminology settles into idiomatic form once “ViewModel” is dropped. Complete code, lightly renamed and not runnable as-is (Next or Parcel will get you there):

import { PostDetails, LikeButton } from './client';
 
export function PostDetailsRoute({ postId }) {
  return <Post postId={postId} />
}
 
export async function PostListRoute() {
  const postIds = await getRecentPostIds();
  return (
    <>
      {postIds.map(postId =>
        <Post key={postId} postId={postId} />
      )}
    </>
  );
}
 
async function Post({
  postId,
  truncateContent,
  includeAvatars
}) {
  const post = await getPost(postId);
  return (
    <PostLayout
      postTitle={post.title}
      postContent={parseMarkdown(post.content, {
        maxParagraphs: truncateContent ? 1 : undefined
      })}
      postAuthor={post.author}
    >
      <PostLikeButton
        postId={postId}
        includeAvatars={includeAvatars}
      />
    </PostLayout>
  );
}
 
async function PostLikeButton({
  postId,
  includeAvatars
}) {
const [post, friendLikes] = await Promise.all([
  getPost(postId),
  getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 }),
]);
return (
  <LikeButton
    totalLikeCount={post.totalLikeCount}
    isLikedByUser={post.isLikedByUser}
    friendLikes={friendLikes.likes.map(l => ({
      firstName: l.firstName,
      avatar: includeAvatars ? l.avatar : null,
    }))}
  />
);
'use client';
 
export function PostLayout({
  postTitle,
  postContent,
  postAuthor,
  children,
}) {
  return (
    <article>
      <h1>{postTitle}</h1>
      <div dangerouslySetInnerHTML={{ __html: postContent }} />
      <p>by {postAuthor.name}</p>
      <section>
        {children}
      </section>
    </article>
  );
}
 
export function LikeButton({
  totalLikeCount,
  isLikedByUser,
  friendLikes
}) {
  let buttonText = 'Like';
  if (totalLikeCount > 0) {
    buttonText = formatLikeText(totalLikeCount, isLikedByUser, friendLikes);
  }
  return (
    <button className={isLikedByUser ? 'liked' : ''}>
      {buttonText}
    </button>
  );
}