Skip to content

VitePress Cross-Framework Rendering Strategy

Overview

VitePress + React

The vitepress-rendering-strategies library provides cross-framework component rendering capabilities for the vitepress static site generator, breaking through the limitation that vitepress natively only supports vue components.

Currently only extends support for react component rendering, with future support planned for other mainstream UI frameworks (such as solid, svelte, preact, angular, etc.).

Technical Architecture Overview

This library is inspired by astro's Islands Architecture design, implementing cross-framework component integration on top of vitepress's SSG (Static Site Generation) foundation.

Core Architecture Features:

  • Static-First: Based on vitepress's SSG architecture, components are pre-rendered at build time.
  • Selective hydration: Only components that need interaction are activated on the client side.
  • Framework Isolation: Each framework component runs independently, with each component container completing hydration independently, avoiding global state conflicts.
  • Progressive Enhancement: Prioritizes static content, gradually enhancing to interactive applications through different rendering strategies.

Feature Highlights

  • Cross-Framework Support: Currently provides native support for react component rendering in vitepress, with future expansion to other mainstream frameworks.
  • Diverse Rendering Strategies: Referencing astro's template directives. Client directives currently support client:only, client:load, client:visible rendering modes, with ssr:only as the default rendering mode.
  • SPA Routing Optimization: spa:sync-render (abbreviated as spa:sr) directive optimizes SPA route switching performance.
  • One-way Data Transfer: Supports passing props from vue to react child components during initial component rendering for initializing React components. This is a one-time transfer, not reactive binding.
  • Development Experience: Complete HMR support providing smooth development experience.
  • Environment Consistency: Maintains consistent rendering strategies between development and production environments, avoiding rendering issues caused by environment inconsistencies.
  • Support for MPA Mode: Fully compatible with vitepress's MPA mode. Even in MPA mode, react component rendering and hydration work normally.

Design Intent of spa:sync-render Directive

vitepress is an SSG application that completes page pre-rendering work during the build phase, with controlled client-side routing. Initial page rendering completes client-side hydration (filtering static nodes) work. When routes change, vitepress loads the client-side scripts that the target route page depends on, completing partial client-side rendering work. This is the typical architecture of SSG applications.

By default, vitepress-rendering-strategies integrates all components on the target page that require pre-rendering (i.e., non-Vue components) into a single, separate script. During a route change, this script is preloaded. After the main Vue rendering is complete, the pre-rendered output is injected into the root container node. If a component also requires hydration, its client-side script is then loaded to complete the client-side hydration process. While this ensures consistent component rendering behavior across route changes, it introduces a classic problem: the performance advantages of pre-rendering are nullified in client-side navigation scenarios, and it can cause component flickering issues.

The following demo environment is running with CPU: 20x slowdown and 0.75x playback speed:

spa:sync-render:disable

In vitepress's SPA route switching, vue content updates are synchronous, but loading and rendering pre-rendered HTML of non-vue components (such as react) is asynchronous. This time difference causes visual flickering and makes the performance advantages of pre-rendering lost during switching.

The SSG architecture strategy adopted by vitepress is reasonable, and we do not intend to adjust the overall architecture. The goal is to enhance the performance advantages of pre-rendering as much as possible on the existing architecture. For this purpose, we provide the spa:sync-render (abbreviated as spa:sr) directive, which integrates the pre-rendered output of all components using this directive on the target page into vue's client-side rendering script, following vue's client-side rendering work and synchronously completing the rendering work of pre-rendered output, so users will not see component flicker issues in special scenarios.

spa:sync-render

Documentation-oriented projects themselves are not recommended to integrate a large number of high-load, highly interactive rendering components. Documentation-oriented projects are more concerned with the time it takes to deliver the main content to the user. We assume that such components are non-critical rendering components. We do not recommend enabling the spa:sync-render directive for these components, as it increases the size of the vue client-side rendering script and also requires loading additional scripts to complete the component's rendering, which may delay the delivery of the main content.

Client Bundle Size Increase Explanation

When vitepress initially renders a page (not route switching), it completes the application's hydration work through a simplified vue client script (.lean.js). Simplified means that vitepress filters out all static nodes during compilation to reduce the script size for initial hydration.

When routes switch, vitepress loads the client-side scripts that the target route page depends on, completing partial client-side rendering work. This is a complete client-side rendering, and client-side scripts must contain all information for rendering components.

The size increase mentioned above only applies to client-side scripts loaded during route switching, and does not affect the vue client script (.lean.js) size during initial page rendering (not route switching).

We provide this directive to meet the synchronous rendering needs of critical rendering components, but developers should be cautious about its impact on client bundle size.

The ssr:only directive implies that the component is purely static. We assume such components are critical rendering components, so their rendering priority is aligned with vue components by default to eliminate visual inconsistencies during route transitions.

The core of spa:sr is to make a trade-off between a smoother route transition experience and smaller client bundle size. Please carefully evaluate whether your component is a critical component that requires synchronous rendering.

Based on the considerations above, we have established the following default rules:

  • All components using a client:* directive do not have the spa:sync-render directive enabled by default, unless the spa:sync-render (or spa:sr) directive is explicitly enabled.
  • All components using the ssr:only directive (including components with no directive) have the spa:sync-render (or spa:sr) directive enabled by default, unless explicitly enabled with spa:sync-render:disable (or spa:sr:disable) directive.

Usage

md
<script setup>
  import VueComp1 from './rendering-strategy-comps/vue/VueComp1.vue';
  const page = {
    title: 'Rendering Strategy',
  };
  const vueUniqueId = 'vue-unique-id';
</script>

<script lang="react">
  import ReactComp1 from './rendering-strategy-comps/react/ReactComp1';
  import { ReactComp2 } from './rendering-strategy-comps/react/ReactComp2';
  import ReactComp3 from './rendering-strategy-comps/react/ReactComp3';
  import { ReactComp4 } from './rendering-strategy-comps/react/ReactComp4';
  import { ReactComp5 } from './rendering-strategy-comps/react/ReactComp5';
  import ReactVueSharedComp from './rendering-strategy-comps/react/ReactVueSharedComp';
</script>

Strategy Design

The vitepress-rendering-strategies cross-framework rendering strategy currently provides four core rendering modes for react components, with each mode optimized for specific application scenarios.

Usage Notes

  1. Component tag naming

    • Must start with an uppercase letter (React-style): e.g., MyComp.
    • The tag name must exactly match the locally imported name in the same .md file's <script lang="react"> block. If you import import { Landing as HomeLanding } from '...';, then the tag must be <HomeLanding ... />.
    • Any mismatch will be skipped at compile time with a warning.
  2. Self-closing only

    • React components in markdown must be written as self-closing: <Comp ... />.
    • Non-self-closing forms like <Comp>...</Comp> will be skipped with a warning.
  3. Location and imports

    • Components must be imported in the same markdown page inside a <script lang="react"> block. Unimported components are ignored.
    • Components can be used inside Vue slots/templates (e.g., within <template #default>...</template>); they will still be correctly discovered and transformed.
  4. Props passing (one-time)

    • All non-strategy attributes on the tag are passed to the React component as props (strings). Vue bindings like :page-title="page.title" are evaluated by Vue first and written as DOM attributes, then forwarded as props during React render/hydration. This is a one-time data pass, not reactive.
    • Do not pass functions or event handlers via attributes (e.g., onClick); bridge of callable props/events is not supported across frameworks.

Client:Only

Feature Analysis:

  1. Suitable for client-side components with strong dependencies on the host environment, such as components that depend on the browser host environment's window, document objects or host environment API.

  2. This mode is typically used for rendering non-critical or lightweight components, benefiting TTFB metrics (reducing server load), but not beneficial for FCP, LCP metrics (no content on first screen), TTI metrics (need to wait for JS loading), and SEO (content not in initial HTML). The impact on INP metrics depends on component complexity.

  3. This mode has low (or almost no) server load, with the entire rendering overhead borne entirely by the user's host environment. Providers can typically host scripts on CDN or use it as a fallback solution during high server load.

  4. This mode has low mental burden for developers. It's commonly used when there's no need to integrate complex rendering logic in development environments and when partially updating components in production environments. It's currently the most common rendering mode.

md
<script lang="react">
  import ReactComp1 from './rendering-strategy-comps/react/ReactComp1';
</script>

<ReactComp1 client:only render-strategy="client:only" component-name="ReactComp1" :page-title="page.title" :render-count="1" />
tsx
import { useState } from 'react';
import type { CompProps } from '../type';
import './css/rc1.css';

export default function ReactComp1(props: CompProps) {
  const [count, setCount] = useState(0);
  return (
    <div className="react-comp1-demo">
      <strong>
        {props['render-count']}: Rendering Strategy:{' '}
        {props['render-strategy']}
      </strong>
      <ol>
        <li>
          <strong>Component Name:</strong>{' '}
          <span>{props['component-name']}</span>
        </li>
        <li>
          <strong>Page Title:</strong> <span>{props['page-title']}</span>
        </li>
        <li>
          <button
            className="rc1-button"
            onClick={() => setCount(count + 1)}
            type="button"
          >
            Click Me!
          </button>
          <strong>Client-Only Rendering Mode, React Instance Count:</strong>{' '}
          <span>{count}</span>
        </li>
      </ol>
    </div>
  );
}
css
.rc1-button {
  padding: 5px;
  border-radius: 8px;
  font-size: 14px;
  margin-right: 8px;
  background-color: #56a8ab;
  color: #9ee2d3;
  border: none;
}

Pre-processed to:

html
<div
  __vrite__react_render_id__="8b05459e"
  __vrite__react_render_directive__="client:only"
  __vrite__react_render_component__="ReactComp1"
  __vrite__react_spa_sync_render__="false"
  render-strategy="client:only"
  component-name="ReactComp1"
  page-title="Rendering Strategy"
  render-count="1"
></div>

Rendering result:



SSR:Only

Feature Analysis:

  1. Suitable for pure static content components, such as data display, SEO critical content, and other components that don't require client-side interaction. Server rendering priority strategy is the most commonly used rendering strategy for document content-oriented (SSG). This is the default rendering strategy for vitepress-rendering-strategies. astro also adopts this strategy as the default rendering strategy:

    Astro leverages server rendering over client-side rendering in the browser as much as possible.

  2. Combined with SSG mode, pre-rendering overhead only occurs during project build time. After build completion, the generated static HTML can be hosted on CDN without affecting production server load. If specific real-time rendering support is needed, it can be combined with ISR for implementation. This mode can also serve as a fallback solution during high server load.

  3. Except for not being beneficial for real-time rendering and interactivity requirements, this mode is beneficial for all other metrics (FCP, LCP, SEO, etc.) while avoiding increases in client-side JavaScript bundle size.

This is the default rendering strategy for vitepress-rendering-strategies, aligning with the core needs of document-oriented projects.

md
<script lang="react">
  import { ReactComp2 } from './rendering-strategy-comps/react/ReactComp2';
</script>

<ReactComp2 ssr:only spa:sr render-strategy="ssr:only" component-name="ReactComp2" :page-title="page.title" :render-count="2" />
tsx
import { useState } from 'react';
import type { CompProps } from '../type';
import './css/rc2.css';

export function ReactComp2(props: CompProps) {
  const [count, setCount] = useState(0);
  return (
    <div className="react-comp2-demo">
      <strong>
        {props['render-count']}: Rendering Strategy:{' '}
        {props['render-strategy']}
      </strong>
      <ol>
        <li>
          <strong>Component Name:</strong>{' '}
          <span>{props['component-name']}</span>
        </li>
        <li>
          <strong>Page Title:</strong> <span>{props['page-title']}</span>
        </li>
        <li>
          <button
            className="rc2-button"
            onClick={() => setCount(count + 1)}
            type="button"
          >
            Click Me!
          </button>
          <strong>Pre-rendering Mode Only, React Instance Count:</strong>{' '}
          <span>{count}</span>
        </li>
      </ol>
    </div>
  );
}
css
.rc2-button {
  padding: 5px;
  border-radius: 8px;
  font-size: 14px;
  margin-right: 8px;
  background-color: pink;
  color: orange;
  border: none;
}

Pre-processed to:

html
<div
  __vrite__react_render_id__="c46fb2f1"
  __vrite__react_render_directive__="ssr:only"
  __vrite__react_render_component__="ReactComp2"
  __vrite__react_spa_sync_render__="true"
  render-strategy="ssr:only"
  component-name="ReactComp2"
  page-title="Rendering Strategy"
  render-count="2"
></div>

Rendering result:


2: Rendering Strategy: ssr:only
  1. Component Name: ReactComp2
  2. Page Title: Rendering Strategy
  3. Pre-rendering Mode Only, React Instance Count: 0

Client:Load

Feature Analysis:

  1. This is a typical isomorphic application component that requires server-side rendering to improve first-screen performance while needing client-side interaction functionality, suitable for critical component rendering.
  2. Adopts an architecture similar to traditional SSR, pre-rendering components at build time to generate initial HTML, with client-side scripts executing hydration work immediately after loading to take over component interaction. Traditional SSR applications may encounter performance bottleneck issues, including server rendering performance issues during high concurrency and client-side FID, INP metric issues, giving users the feeling of a fake site with weak interactivity. Islands Architecture simplifies the complexity of traditional SSR architecture, allowing each component container to independently complete rendering and hydration processes without waiting for all components to finish rendering before performing one-time root container hydration.
  3. Note that this is different from traditional SSR architecture - this completes hydration work on top of SSG architecture. Pre-rendering is completed at build time, generating static HTML, rather than runtime rendering in traditional SSR. After build completion, it can be hosted on CDN without affecting production server load. Therefore, using this mode compared to ssr:only mode adds client-side hydration process, with this overhead borne by CDN and user host environment.
  4. This mode typically benefits FCP, LCP metrics (quickly displaying content) but is not beneficial for TTI metrics (requires hydration time). The impact on FID, INP metrics depends on the degree of main thread blocking during hydration and component complexity.
md
<script lang="react">
  import ReactComp3 from './rendering-strategy-comps/react/ReactComp3';
</script>

<ReactComp3 client:load spa:sync-render render-strategy="client:load" component-name="ReactComp3" :page-title="page.title" :render-count="3" />
tsx
import { useState } from 'react';
import type { CompProps } from '../type';
import './css/rc3.css';

export default function ReactComp3(props: CompProps) {
  const [count, setCount] = useState(0);
  return (
    <div className="react-comp3-demo">
      <strong>
        {props['render-count']}: Rendering Strategy:{' '}
        {props['render-strategy']}
      </strong>
      <ol>
        <li>
          <strong>Component Name:</strong>{' '}
          <span>{props['component-name']}</span>
        </li>
        <li>
          <strong>Page Title:</strong> <span>{props['page-title']}</span>
        </li>
        <li>
          <button
            className="rc3-button"
            onClick={() => setCount(count + 1)}
            type="button"
          >
            Click Me!
          </button>
          <strong>
            Pre-rendering Client Hydration Mode, React Instance Count:
          </strong>{' '}
          <span>{count}</span>
        </li>
      </ol>
    </div>
  );
}
css
.rc3-button {
  padding: 5px;
  border-radius: 8px;
  font-size: 14px;
  margin-right: 8px;
  background-color: #9ceaca63;
  color: #1dd270;
  border: none;
}

Pre-processed to:

md
<div
  __vrite__react_render_id__="ac62f9f7"
  __vrite__react_render_directive__="client:load"
  __vrite__react_render_component__="ReactComp3"
  __vrite__react_spa_sync_render__="true"
  render-strategy="client:load"
  component-name="ReactComp3"
  page-title="Rendering Strategy"
  render-count="3"
></div>

Rendering result:


3: Rendering Strategy: client:load
  1. Component Name: ReactComp3
  2. Page Title: Rendering Strategy
  3. Pre-rendering Client Hydration Mode, React Instance Count: 0

Client:Visible

Feature Analysis:

  1. Suitable for interactive components that are not critical content on the first screen, such as comment systems at the bottom of pages, chart components, etc. However, note that component scripts will adopt preloading strategy by default, not pure lazy loading.
  2. Features can refer to client:load.
md
<script lang="react">
  import { ReactComp4 } from './rendering-strategy-comps/react/ReactComp4';
</script>

<ReactComp4 client:visible render-strategy="client:visible" component-name="ReactComp4" :page-title="page.title" :render-count="4" />
tsx
import { useState } from 'react';
import type { CompProps } from '../type';

export function ReactComp4(props: CompProps) {
  const [count, setCount] = useState(0);
  return (
    <div className="react-comp4-demo">
      <strong>
        {props['render-count']}: Rendering Strategy:{' '}
        {props['render-strategy']}
      </strong>
      <ol>
        <li>
          <strong>Component Name:</strong>{' '}
          <span>{props['component-name']}</span>
        </li>
        <li>
          <strong>Page Title:</strong> <span>{props['page-title']}</span>
        </li>
        <li>
          <button
            style={{
              padding: '5px',
              borderRadius: '8px',
              fontSize: '14px',
              marginRight: '8px',
              backgroundColor: '#56a8ab',
              color: '#9ee2d3',
              border: 'none'
            }}
            onClick={() => setCount(count + 1)}
            type="button"
          >
            Click Me!
          </button>
          <strong>
            Pre-rendering Client Visible Hydration Mode, React Instance
            Count:
          </strong>{' '}
          <span>{count}</span>
        </li>
      </ol>
    </div>
  );
}

Pre-processed to:

md
<div
  __vrite__react_render_id__="af2c1304"
  __vrite__react_render_directive__="client:visible"
  __vrite__react_render_component__="ReactComp4"
  __vrite__react_spa_sync_render__="false"
  render-strategy="client:visible"
  component-name="ReactComp4"
  page-title="Rendering Strategy"
  render-count="4"
></div>

Rendering result:


4: Rendering Strategy: client:visible
  1. Component Name: ReactComp4
  2. Page Title: Rendering Strategy
  3. Pre-rendering Client Visible Hydration Mode, React Instance Count: 0

Default Strategy

The default rendering strategy is equivalent to ssr:only mode. For details, see ssr:only.

md
<script lang="react">
  import { ReactComp5 } from './rendering-strategy-comps/react/ReactComp5';
</script>

<ReactComp5 render-strategy="default" component-name="ReactComp5" :page-title="page.title" :render-count="5" />
tsx
import { useState } from 'react';
import type { CompProps } from '../type';

export function ReactComp5(props: CompProps) {
  const [count, setCount] = useState(0);
  return (
    <div className="react-comp5-demo">
      <strong>
        {props['render-count']}: Rendering Strategy:{' '}
        {props['render-strategy']}
      </strong>
      <ol>
        <li>
          <strong>Component Name:</strong>{' '}
          <span>{props['component-name']}</span>
        </li>
        <li>
          <strong>Page Title:</strong> <span>{props['page-title']}</span>
        </li>
        <li>
          <button
            style={{
              padding: '5px',
              borderRadius: '8px',
              fontSize: '14px',
              marginRight: '8px',
              backgroundColor: '#56a8ab',
              color: '#9ee2d3',
              border: 'none'
            }}
            onClick={() => setCount(count + 1)}
            type="button"
          >
            Click Me!
          </button>
          <strong>
            Default Rendering Mode (Pre-rendering Mode Only), React Instance
            Count:
          </strong>{' '}
          <span>{count}</span>
        </li>
      </ol>
    </div>
  );
}

Pre-processed to:

md
<div
  __vrite__react_render_id__="f4dd2447"
  __vrite__react_render_directive__="ssr:only"
  __vrite__react_render_component__="ReactComp5"
  __vrite__react_spa_sync_render__="false"
  render-strategy="default"
  component-name="ReactComp5"
  page-title="Rendering Strategy"
  render-count="5"
></div>

Rendering result:


5: Rendering Strategy: default
  1. Component Name: ReactComp5
  2. Page Title: Rendering Strategy
  3. Default Rendering Mode (Pre-rendering Mode Only), React Instance Count: 0

Rendering Strategy Combination

This library supports nested usage of vue components and react components. During component initial rendering, the vue parent component can one-time pass data as props to react child components through slot for initializing react component state.

The initial snapshot of the rendering root container is first processed by the vue rendering engine, then completed by the corresponding UI framework's rendering work, so the rendering component's props can access the root container snapshot properties.

playground.md
vue
<script setup>
import VueComp1 from './rendering-strategy-comps/vue/VueComp1.vue';
const page = {
  title: 'Rendering Strategy'
};
const vueUniqueId = 'vue-unique-id';
</script>

<script lang="react">
import ReactVueSharedComp from './rendering-strategy-comps/react/ReactVueSharedComp';
</script>

<VueComp1
  :unique-id="vueUniqueId"
  render-strategy="client:only"
  component-name="VueComp1"
  :page-title="page.title"
  :render-count="6"
>
  <template #default="{ vueInfo }">
    <ReactVueSharedComp client:only render-strategy="client:only" component-name="ReactVueSharedComp" :page-title="page.title" render-count="3-7" :vue-info="vueInfo" />
  </template>
</VueComp1>
tsx
import { useState } from 'react';
import type { CompProps } from '../type';

interface ReactVueSharedCompProps extends CompProps {
  'vue-info': string;
}

export default function ReactVueSharedComp(props: ReactVueSharedCompProps) {
  const [count, setCount] = useState(0);
  return (
    <div className="react-vue-shared-comp">
      <strong>
        {props['render-count']}: Rendering Strategy:{' '}
        {props['render-strategy']}
      </strong>
      <ol>
        <li>
          <strong>Component Name:</strong>{' '}
          <span>{props['component-name']}</span>
        </li>
        <li>
          <strong>Page Title:</strong> <span>{props['page-title']}</span>
        </li>
        <li>
          <strong>Vue Component Info:</strong>{' '}
          <span>{props['vue-info']}</span>
        </li>
        <li>
          <button
            style={{
              padding: '5px',
              borderRadius: '8px',
              fontSize: '14px',
              marginRight: '8px',
              backgroundColor: '#56a8ab',
              color: '#9ee2d3',
              border: 'none'
            }}
            onClick={() => setCount(count + 1)}
            type="button"
          >
            Click Me!
          </button>
          <strong>Client-Only Rendering Mode, React Instance Count:</strong>{' '}
          <span>{count}</span>
        </li>
      </ol>
    </div>
  );
}
vue
<script setup lang="ts">
export interface CompProps {
  componentName: string;
  renderStrategy: string;
  pageTitle: string;
  renderCount: number;
}

const props = defineProps<CompProps>();
const vueInfo = 'VueComp1';
</script>

<template>
  <div class="vue-comp1-demo">
    <strong>
      {{ props.renderCount }}: Rendering Strategy:
      {{ props.renderStrategy }}
    </strong>
    <ol>
      <li>
        <strong>Component Name:</strong>
        <span>{{ props.componentName }}</span>
      </li>
      <li>
        <strong>Page Title:</strong> <span>{{ props.pageTitle }}</span>
      </li>
      <li>
        <strong>Child Component Rendering:</strong>
        <slot :vue-info="vueInfo"></slot>
      </li>
    </ol>
  </div>
</template>

Rendering result:


6: Rendering Strategy: client:only
  1. Component Name: VueComp1
  2. Page Title: Rendering Strategy
  3. Child Component Rendering:

Integration

To enable cross-framework rendering strategies in a vitepress project, you need to introduce the corresponding plugins in the build configuration:

ts
import { defineConfig } from 'vitepress';
import vitepressReactRenderingStrategies from 'vitepress-rendering-strategies/react';

const vitePressConfig = defineConfig({
  // ...
});

vitepressReactRenderingStrategies(vitePressConfig);

export default vitePressConfig;
ts
import DefaultTheme from 'vitepress/theme';
import reactClientIntegration from 'vitepress-rendering-strategies/react/client';
import type { Theme } from 'vitepress';

const theme: Theme = {
  extends: DefaultTheme,
  async enhanceApp(context) {
    await reactClientIntegration();
  }
};

export default theme;

Important Notes

  1. Project Structure Consistency: The project structure must be consistent with the routing configuration, otherwise rendering strategies may fail.

  2. Error Handling Mechanism: When component hydration fails, the system will automatically fall back to client-side rendering mode to ensure user experience is not affected.

  3. Performance Best Practices:

    • Prioritize using ssr:only mode for static content.
    • Only use client:load for critical components that need interaction.
    • Non-first-screen components are recommended to use client:visible for delayed loading.
    • Avoid using a large number of spa:sr directives on a single page to prevent affecting main content loading.

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2bfade1)