Skip to content

Virtual Modules

As covered in Project Structure, @fastify/react relies on virtual modules to save your project from having too many boilerplate files. Virtual modules used in @fastify/react are fully ejectable. For instance, the starter template relies on the /:root.jsx virtual module to provide the React component shell of your application. If you copy the root.jsx file from the @fastify/react package and place it your Vite project root, that copy of the file is used instead.

/:root.jsx

This is the root React component. It's used internally by /:create.jsx and provided as part of the starter template. You can use this file to add a common layout to all routes, or just use it to add additional, custom context providers.

Note that a top-level <Suspense> wrapper is necessary because @fastify/react has code-splitting enabled at the route-level. You can opt out of code-splitting by providing your own routes.js file, but that's very unlikely to be ever required for any reason.

Source from packages/fastify-react/virtual/root.jsx:

jsx
import { Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { AppRoute, Router } from '/:core.jsx'

export default function Root({ url, routes, head, ctxHydration, routeMap }) {
  return (
    <Suspense>
      <Router location={url}>
        <Routes>
          {routes.map(({ path, component: Component }) => (
            <Route
              key={path}
              path={path}
              element={
                <AppRoute
                  head={head}
                  ctxHydration={ctxHydration}
                  ctx={routeMap[path]}
                >
                  <Component />
                </AppRoute>
              }
            />
          ))}
        </Routes>
      </Router>
    </Suspense>
  )
}
import { Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { AppRoute, Router } from '/:core.jsx'

export default function Root({ url, routes, head, ctxHydration, routeMap }) {
  return (
    <Suspense>
      <Router location={url}>
        <Routes>
          {routes.map(({ path, component: Component }) => (
            <Route
              key={path}
              path={path}
              element={
                <AppRoute
                  head={head}
                  ctxHydration={ctxHydration}
                  ctx={routeMap[path]}
                >
                  <Component />
                </AppRoute>
              }
            />
          ))}
        </Routes>
      </Router>
    </Suspense>
  )
}

/:routes.js

@fastify/react has code-splitting out of the box. It does that by eagerly loading all route data on the server, and then hydrating any missing metadata on the client. That's why the routes module default export is conditioned to import.meta.env.SSR, and different helper functions are called for each rendering environment.

React Router's nested routes aren't supported yet.

Source from packages/fastify-react/virtual/routes.js:

js
/* global $paramPattern */

import { lazy } from 'react'

export default import.meta.env.SSR
  ? createRoutes(import.meta.glob('$globPattern', { eager: true }))
  : hydrateRoutes(import.meta.glob('$globPattern'))

async function createRoutes(from, { param } = { param: $paramPattern }) {
  // Otherwise we get a ReferenceError, but since
  // this function is only ran once, there's no overhead
  class Routes extends Array {
    toJSON() {
      return this.map((route) => {
        return {
          id: route.id,
          path: route.path,
          layout: route.layout,
          getData: !!route.getData,
          getMeta: !!route.getMeta,
          onEnter: !!route.onEnter,
        }
      })
    }
  }
  const importPaths = Object.keys(from)
  const promises = []
  if (Array.isArray(from)) {
    for (const routeDef of from) {
      promises.push(
        getRouteModule(routeDef.path, routeDef.component).then(
          (routeModule) => {
            return {
              id: routeDef.path,
              path: routeDef.path ?? routeModule.path,
              ...routeModule,
            }
          },
        ),
      )
    }
  } else {
    // Ensure that static routes have precedence over the dynamic ones
    for (const path of importPaths.sort((a, b) => (a > b ? -1 : 1))) {
      promises.push(
        getRouteModule(path, from[path]).then((routeModule) => {
          return {
            id: path,
            layout: routeModule.layout,
            path:
              routeModule.path ??
              path
                // Remove /pages and .jsx extension
                .slice(6, -4)
                // Replace [id] with :id
                .replace(param, (_, m) => `:${m}`)
                // Replace '/index' with '/'
                .replace(/\/index$/, '/')
                // Remove trailing slashs
                .replace(/(.+)\/+$/, (...m) => m[1]),
            ...routeModule,
          }
        }),
      )
    }
  }
  return new Routes(...(await Promise.all(promises)))
}

async function hydrateRoutes(fromInput) {
  let from = fromInput
  if (Array.isArray(from)) {
    from = Object.fromEntries(from.map((route) => [route.path, route]))
  }
  return window.routes.map((route) => {
    route.loader = memoImport(from[route.id])
    route.component = lazy(() => route.loader())
    return route
  })
}

function getRouteModuleExports(routeModule) {
  return {
    // The Route component (default export)
    component: routeModule.default,
    // The Layout Route component
    layout: routeModule.layout,
    // Route-level hooks
    getData: routeModule.getData,
    getMeta: routeModule.getMeta,
    onEnter: routeModule.onEnter,
    // Other Route-level settings
    streaming: routeModule.streaming,
    clientOnly: routeModule.clientOnly,
    serverOnly: routeModule.serverOnly,
  }
}

async function getRouteModule(path, routeModuleInput) {
  let routeModule = routeModuleInput
  // const isServer = typeof process !== 'undefined'
  if (typeof routeModule === 'function') {
    routeModule = await routeModule()
    return getRouteModuleExports(routeModule)
  }
  return getRouteModuleExports(routeModule)
}

function memoImport(func) {
  // Otherwise we get a ReferenceError, but since this function
  // is only ran once for each route, there's no overhead
  const kFuncExecuted = Symbol('kFuncExecuted')
  const kFuncValue = Symbol('kFuncValue')
  func[kFuncExecuted] = false
  return async () => {
    if (!func[kFuncExecuted]) {
      func[kFuncValue] = await func()
      func[kFuncExecuted] = true
    }
    return func[kFuncValue]
  }
}
/* global $paramPattern */

import { lazy } from 'react'

export default import.meta.env.SSR
  ? createRoutes(import.meta.glob('$globPattern', { eager: true }))
  : hydrateRoutes(import.meta.glob('$globPattern'))

async function createRoutes(from, { param } = { param: $paramPattern }) {
  // Otherwise we get a ReferenceError, but since
  // this function is only ran once, there's no overhead
  class Routes extends Array {
    toJSON() {
      return this.map((route) => {
        return {
          id: route.id,
          path: route.path,
          layout: route.layout,
          getData: !!route.getData,
          getMeta: !!route.getMeta,
          onEnter: !!route.onEnter,
        }
      })
    }
  }
  const importPaths = Object.keys(from)
  const promises = []
  if (Array.isArray(from)) {
    for (const routeDef of from) {
      promises.push(
        getRouteModule(routeDef.path, routeDef.component).then(
          (routeModule) => {
            return {
              id: routeDef.path,
              path: routeDef.path ?? routeModule.path,
              ...routeModule,
            }
          },
        ),
      )
    }
  } else {
    // Ensure that static routes have precedence over the dynamic ones
    for (const path of importPaths.sort((a, b) => (a > b ? -1 : 1))) {
      promises.push(
        getRouteModule(path, from[path]).then((routeModule) => {
          return {
            id: path,
            layout: routeModule.layout,
            path:
              routeModule.path ??
              path
                // Remove /pages and .jsx extension
                .slice(6, -4)
                // Replace [id] with :id
                .replace(param, (_, m) => `:${m}`)
                // Replace '/index' with '/'
                .replace(/\/index$/, '/')
                // Remove trailing slashs
                .replace(/(.+)\/+$/, (...m) => m[1]),
            ...routeModule,
          }
        }),
      )
    }
  }
  return new Routes(...(await Promise.all(promises)))
}

async function hydrateRoutes(fromInput) {
  let from = fromInput
  if (Array.isArray(from)) {
    from = Object.fromEntries(from.map((route) => [route.path, route]))
  }
  return window.routes.map((route) => {
    route.loader = memoImport(from[route.id])
    route.component = lazy(() => route.loader())
    return route
  })
}

function getRouteModuleExports(routeModule) {
  return {
    // The Route component (default export)
    component: routeModule.default,
    // The Layout Route component
    layout: routeModule.layout,
    // Route-level hooks
    getData: routeModule.getData,
    getMeta: routeModule.getMeta,
    onEnter: routeModule.onEnter,
    // Other Route-level settings
    streaming: routeModule.streaming,
    clientOnly: routeModule.clientOnly,
    serverOnly: routeModule.serverOnly,
  }
}

async function getRouteModule(path, routeModuleInput) {
  let routeModule = routeModuleInput
  // const isServer = typeof process !== 'undefined'
  if (typeof routeModule === 'function') {
    routeModule = await routeModule()
    return getRouteModuleExports(routeModule)
  }
  return getRouteModuleExports(routeModule)
}

function memoImport(func) {
  // Otherwise we get a ReferenceError, but since this function
  // is only ran once for each route, there's no overhead
  const kFuncExecuted = Symbol('kFuncExecuted')
  const kFuncValue = Symbol('kFuncValue')
  func[kFuncExecuted] = false
  return async () => {
    if (!func[kFuncExecuted]) {
      func[kFuncValue] = await func()
      func[kFuncExecuted] = true
    }
    return func[kFuncValue]
  }
}

/:core.jsx

Implements useRouteContext(), App and AppRoute.

App is imported by root.jsx and encapsulates @fastify/react's route component API.

Source from packages/fastify-react/virtual/core.jsx:

jsx
import { createPath } from 'history'
import { createContext, useContext, useEffect } from 'react'
import { BrowserRouter, useLocation } from 'react-router-dom'
import { StaticRouter } from 'react-router-dom/server.mjs'
import { proxy, useSnapshot } from 'valtio'
import layouts from '/:layouts.js'
import { waitFetch, waitResource } from '/:resource.js'

export const isServer = import.meta.env.SSR
export const Router = isServer ? StaticRouter : BrowserRouter
export const RouteContext = createContext({})

export function useRouteContext() {
  const routeContext = useContext(RouteContext)
  if (routeContext.state) {
    routeContext.snapshot = isServer
      ? routeContext.state ?? {}
      : useSnapshot(routeContext.state ?? {})
  }
  return routeContext
}

export function AppRoute({ head, ctxHydration, ctx, children }) {
  // If running on the server, assume all data
  // functions have already ran through the preHandler hook
  if (isServer) {
    const Layout = layouts[ctxHydration.layout ?? 'default']
    return (
      <RouteContext.Provider
        value={{
          ...ctx,
          ...ctxHydration,
          state: isServer
            ? ctxHydration.state ?? {}
            : proxy(ctxHydration.state ?? {}),
        }}
      >
        <Layout>{children}</Layout>
      </RouteContext.Provider>
    )
  }
  // Note that on the client, window.route === ctxHydration

  // Indicates whether or not this is a first render on the client
  ctx.firstRender = window.route.firstRender

  // If running on the client, the server context data
  // is still available, hydrated from window.route
  if (ctx.firstRender) {
    ctx.data = window.route.data
    ctx.head = window.route.head
  }

  const location = useLocation()
  const path = createPath(location)

  // When the next route renders client-side,
  // force it to execute all URMA hooks again
  // biome-ignore lint/correctness/useExhaustiveDependencies: I'm inclined to believe you, Biome, but I'm not risking it.
  useEffect(() => {
    window.route.firstRender = false
  }, [location])

  // If we have a getData function registered for this route
  if (!ctx.data && ctx.getData) {
    try {
      const { pathname, search } = location
      // If not, fetch data from the JSON endpoint
      ctx.data = waitFetch(`${pathname}${search}`)
    } catch (status) {
      // If it's an actual error...
      if (status instanceof Error) {
        ctx.error = status
      }
      // If it's just a promise (suspended state)
      throw status
    }
  }

  // Note that ctx.loader() at this point will resolve the
  // memoized module, so there's barely any overhead

  if (!ctx.firstRender && ctx.getMeta) {
    const updateMeta = async () => {
      const { getMeta } = await ctx.loader()
      head.update(await getMeta(ctx))
    }
    waitResource(path, 'updateMeta', updateMeta)
  }

  if (!ctx.firstRender && ctx.onEnter) {
    const runOnEnter = async () => {
      const { onEnter } = await ctx.loader()
      const updatedData = await onEnter(ctx)
      if (!ctx.data) {
        ctx.data = {}
      }
      Object.assign(ctx.data, updatedData)
    }
    waitResource(path, 'onEnter', runOnEnter)
  }

  const Layout = layouts[ctx.layout ?? 'default']

  return (
    <RouteContext.Provider
      value={{
        ...ctxHydration,
        ...ctx,
        state: isServer
          ? ctxHydration.state ?? {}
          : proxy(ctxHydration.state ?? {}),
      }}
    >
      <Layout>{children}</Layout>
    </RouteContext.Provider>
  )
}
import { createPath } from 'history'
import { createContext, useContext, useEffect } from 'react'
import { BrowserRouter, useLocation } from 'react-router-dom'
import { StaticRouter } from 'react-router-dom/server.mjs'
import { proxy, useSnapshot } from 'valtio'
import layouts from '/:layouts.js'
import { waitFetch, waitResource } from '/:resource.js'

export const isServer = import.meta.env.SSR
export const Router = isServer ? StaticRouter : BrowserRouter
export const RouteContext = createContext({})

export function useRouteContext() {
  const routeContext = useContext(RouteContext)
  if (routeContext.state) {
    routeContext.snapshot = isServer
      ? routeContext.state ?? {}
      : useSnapshot(routeContext.state ?? {})
  }
  return routeContext
}

export function AppRoute({ head, ctxHydration, ctx, children }) {
  // If running on the server, assume all data
  // functions have already ran through the preHandler hook
  if (isServer) {
    const Layout = layouts[ctxHydration.layout ?? 'default']
    return (
      <RouteContext.Provider
        value={{
          ...ctx,
          ...ctxHydration,
          state: isServer
            ? ctxHydration.state ?? {}
            : proxy(ctxHydration.state ?? {}),
        }}
      >
        <Layout>{children}</Layout>
      </RouteContext.Provider>
    )
  }
  // Note that on the client, window.route === ctxHydration

  // Indicates whether or not this is a first render on the client
  ctx.firstRender = window.route.firstRender

  // If running on the client, the server context data
  // is still available, hydrated from window.route
  if (ctx.firstRender) {
    ctx.data = window.route.data
    ctx.head = window.route.head
  }

  const location = useLocation()
  const path = createPath(location)

  // When the next route renders client-side,
  // force it to execute all URMA hooks again
  // biome-ignore lint/correctness/useExhaustiveDependencies: I'm inclined to believe you, Biome, but I'm not risking it.
  useEffect(() => {
    window.route.firstRender = false
  }, [location])

  // If we have a getData function registered for this route
  if (!ctx.data && ctx.getData) {
    try {
      const { pathname, search } = location
      // If not, fetch data from the JSON endpoint
      ctx.data = waitFetch(`${pathname}${search}`)
    } catch (status) {
      // If it's an actual error...
      if (status instanceof Error) {
        ctx.error = status
      }
      // If it's just a promise (suspended state)
      throw status
    }
  }

  // Note that ctx.loader() at this point will resolve the
  // memoized module, so there's barely any overhead

  if (!ctx.firstRender && ctx.getMeta) {
    const updateMeta = async () => {
      const { getMeta } = await ctx.loader()
      head.update(await getMeta(ctx))
    }
    waitResource(path, 'updateMeta', updateMeta)
  }

  if (!ctx.firstRender && ctx.onEnter) {
    const runOnEnter = async () => {
      const { onEnter } = await ctx.loader()
      const updatedData = await onEnter(ctx)
      if (!ctx.data) {
        ctx.data = {}
      }
      Object.assign(ctx.data, updatedData)
    }
    waitResource(path, 'onEnter', runOnEnter)
  }

  const Layout = layouts[ctx.layout ?? 'default']

  return (
    <RouteContext.Provider
      value={{
        ...ctxHydration,
        ...ctx,
        state: isServer
          ? ctxHydration.state ?? {}
          : proxy(ctxHydration.state ?? {}),
      }}
    >
      <Layout>{children}</Layout>
    </RouteContext.Provider>
  )
}

/:create.jsx

This virtual module creates your root React component.

This is where root.jsx is imported.

Source from packages/fastify-react/virtual/create.jsx:

jsx
import Root from '/:root.jsx'

export default function create({ url, ...serverInit }) {
  return <Root url={url} {...serverInit} />
}
import Root from '/:root.jsx'

export default function create({ url, ...serverInit }) {
  return <Root url={url} {...serverInit} />
}

/:layouts/default.js

This is used internally by /:core.jsx. If a project has no layouts/default.jsx file, the default one from @fastify/react is used.

Source from packages/fastify-react/virtual/layouts/default.jsx:

jsx
// This file serves as a placeholder
// if no layouts/default.jsx file is provided

import { Suspense } from 'react'

export default function Layout({ children }) {
  return <Suspense>{children}</Suspense>
}
// This file serves as a placeholder
// if no layouts/default.jsx file is provided

import { Suspense } from 'react'

export default function Layout({ children }) {
  return <Suspense>{children}</Suspense>
}

/:mount.js

This is the file index.html links to by default. It sets up the application with an unihead instance for head management, the initial route context, and provides the conditional mounting logic to defer to CSR-only if clientOnly is enabled.

Source from packages/fastify-react/virtual/mount.js:

js
import { createRoot, hydrateRoot } from 'react-dom/client'
import Head from 'unihead/client'

import create from '/:create.jsx'
import routesPromise from '/:routes.js'

mount('root')

async function mount(targetInput) {
  let target = targetInput
  if (typeof target === 'string') {
    target = document.getElementById(target)
  }
  const context = await import('/:context.js')
  const ctxHydration = await extendContext(window.route, context)
  const head = new Head(window.route.head, window.document)
  const resolvedRoutes = await routesPromise
  const routeMap = Object.fromEntries(
    resolvedRoutes.map((route) => [route.path, route]),
  )

  const app = create({
    head,
    ctxHydration,
    routes: window.routes,
    routeMap,
  })
  if (ctxHydration.clientOnly) {
    createRoot(target).render(app)
  } else {
    hydrateRoot(target, app)
  }
}

async function extendContext(
  ctx,
  {
    // The route context initialization function
    default: setter,
    // We destructure state here just to discard it from extra
    state,
    // Other named exports from context.js
    ...extra
  },
) {
  Object.assign(ctx, extra)
  if (setter) {
    await setter(ctx)
  }
  return ctx
}
import { createRoot, hydrateRoot } from 'react-dom/client'
import Head from 'unihead/client'

import create from '/:create.jsx'
import routesPromise from '/:routes.js'

mount('root')

async function mount(targetInput) {
  let target = targetInput
  if (typeof target === 'string') {
    target = document.getElementById(target)
  }
  const context = await import('/:context.js')
  const ctxHydration = await extendContext(window.route, context)
  const head = new Head(window.route.head, window.document)
  const resolvedRoutes = await routesPromise
  const routeMap = Object.fromEntries(
    resolvedRoutes.map((route) => [route.path, route]),
  )

  const app = create({
    head,
    ctxHydration,
    routes: window.routes,
    routeMap,
  })
  if (ctxHydration.clientOnly) {
    createRoot(target).render(app)
  } else {
    hydrateRoot(target, app)
  }
}

async function extendContext(
  ctx,
  {
    // The route context initialization function
    default: setter,
    // We destructure state here just to discard it from extra
    state,
    // Other named exports from context.js
    ...extra
  },
) {
  Object.assign(ctx, extra)
  if (setter) {
    await setter(ctx)
  }
  return ctx
}

/:resource.js

Provides the waitResource() and waitFetch() data fetching helpers implementing the Suspense API. They're used by /:core.jsx.

Source from packages/fastify-react/virtual/resource.js:

js
const fetchMap = new Map()
const resourceMap = new Map()

export function waitResource(path, id, promise) {
  const resourceId = `${path}:${id}`
  const loaderStatus = resourceMap.get(resourceId)
  if (loaderStatus) {
    if (loaderStatus.error) {
      throw loaderStatus.error
    }
    if (loaderStatus.suspended) {
      throw loaderStatus.promise
    }
    resourceMap.delete(resourceId)

    return loaderStatus.result
  }
  const loader = {
    suspended: true,
    error: null,
    result: null,
    promise: null,
  }
  loader.promise = promise()
    .then((result) => {
      loader.result = result
    })
    .catch((loaderError) => {
      loader.error = loaderError
    })
    .finally(() => {
      loader.suspended = false
    })

  resourceMap.set(resourceId, loader)

  return waitResource(path, id)
}

export function waitFetch(path) {
  const loaderStatus = fetchMap.get(path)
  if (loaderStatus) {
    if (loaderStatus.error || loaderStatus.data?.statusCode === 500) {
      if (loaderStatus.data?.statusCode === 500) {
        throw new Error(loaderStatus.data.message)
      }
      throw loaderStatus.error
    }
    if (loaderStatus.suspended) {
      throw loaderStatus.promise
    }
    fetchMap.delete(path)

    return loaderStatus.data
  }
  const loader = {
    suspended: true,
    error: null,
    data: null,
    promise: null,
  }
  loader.promise = fetch(`/-/data${path}`)
    .then((response) => response.json())
    .then((loaderData) => {
      loader.data = loaderData
    })
    .catch((loaderError) => {
      loader.error = loaderError
    })
    .finally(() => {
      loader.suspended = false
    })

  fetchMap.set(path, loader)

  return waitFetch(path)
}
const fetchMap = new Map()
const resourceMap = new Map()

export function waitResource(path, id, promise) {
  const resourceId = `${path}:${id}`
  const loaderStatus = resourceMap.get(resourceId)
  if (loaderStatus) {
    if (loaderStatus.error) {
      throw loaderStatus.error
    }
    if (loaderStatus.suspended) {
      throw loaderStatus.promise
    }
    resourceMap.delete(resourceId)

    return loaderStatus.result
  }
  const loader = {
    suspended: true,
    error: null,
    result: null,
    promise: null,
  }
  loader.promise = promise()
    .then((result) => {
      loader.result = result
    })
    .catch((loaderError) => {
      loader.error = loaderError
    })
    .finally(() => {
      loader.suspended = false
    })

  resourceMap.set(resourceId, loader)

  return waitResource(path, id)
}

export function waitFetch(path) {
  const loaderStatus = fetchMap.get(path)
  if (loaderStatus) {
    if (loaderStatus.error || loaderStatus.data?.statusCode === 500) {
      if (loaderStatus.data?.statusCode === 500) {
        throw new Error(loaderStatus.data.message)
      }
      throw loaderStatus.error
    }
    if (loaderStatus.suspended) {
      throw loaderStatus.promise
    }
    fetchMap.delete(path)

    return loaderStatus.data
  }
  const loader = {
    suspended: true,
    error: null,
    data: null,
    promise: null,
  }
  loader.promise = fetch(`/-/data${path}`)
    .then((response) => response.json())
    .then((loaderData) => {
      loader.data = loaderData
    })
    .catch((loaderError) => {
      loader.error = loaderError
    })
    .finally(() => {
      loader.suspended = false
    })

  fetchMap.set(path, loader)

  return waitFetch(path)
}

Released under the MIT License.