Using React and Vue.js Assets in a BowPHP Project
Most applications are mostly server-rendered HTML with a few islands of rich interactivity β a dashboard widget, a live search box, a multi-step form. BowPHP ships with a modern, Vite-powered frontend out of the box, so you can drop a React or Vue component exactly where you need one without turning your whole app into a single-page application.
In this post we'll wire up both: an interactive React counter and a Vue widget, mounted side by side into a server-rendered template.
What you get out of the boxβ
A fresh BowPHP app already comes preconfigured with:
- React 19
- Vue.js 3
- Tailwind CSS
- Sass/SCSS
β¦all bundled by Vite, with hot module replacement (HMR) in development and hashed, cache-busted files in production. You don't have to configure a build pipeline β it's there. See the Front-End Assets docs for the full reference.
Step 1 β Install the frontend dependenciesβ
The PHP side comes from Composer; the JavaScript side comes from npm:
npm install
Step 2 β Know your file structureβ
Everything frontend lives under assets/ and compiles into public/:
assets/
βββ js/
β βββ app.js # Main entry point
β βββ Example.jsx # React component
β βββ Example.vue # Vue component
βββ sass/
β βββ app.scss # Main styles
βββ css/
βββ app.css # CSS/Tailwind
assets/js/app.js is the single entry Vite compiles. Think of it as the place
where you decide which components mount where on the page.
Step 3 β Write a React componentβ
import React, { useState } from 'react';
export default function Example() {
const [count, setCount] = useState(0);
return (
<div className="p-4">
<h1 className="text-2xl font-bold">Counter: {count}</h1>
<button
className="btn-primary mt-4"
onClick={() => setCount(count + 1)}
>
Increment
</button>
</div>
);
}
Step 4 β Write a Vue componentβ
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<template>
<div class="p-4">
<h1 class="text-2xl font-bold">Counter: {{ count }}</h1>
<button class="btn-primary mt-4" @click="count++">
Increment
</button>
</div>
</template>
Step 5 β Mount them from the entry pointβ
The entry point is plain JavaScript. It imports your styles, then mounts each framework onto a DOM node only if that node exists on the current page β that's the trick that keeps a React widget out of the way of a Vue one, and both out of the way of pages that need neither.
import '../css/app.css';
// --- React island ---
import React from 'react';
import { createRoot } from 'react-dom/client';
import Example from './Example.jsx';
const reactRoot = document.getElementById('react-app');
if (reactRoot) {
createRoot(reactRoot).render(React.createElement(Example));
}
// --- Vue island ---
import { createApp } from 'vue';
import ExampleVue from './Example.vue';
const vueRoot = document.getElementById('vue-app');
if (vueRoot) {
createApp(ExampleVue).mount('#vue-app');
}
Because each block is guarded by an if, the same app.js can serve every
page. A page that has neither #react-app nor #vue-app simply renders nothing
extra. When your islands grow, split them with dynamic import() so a page only
downloads the component it actually mounts.
Step 6 β Drop them into a templateβ
Now reference the compiled assets from a server-rendered template and leave two empty mount points for the components:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="{{ vite('css/app.css') }}">
</head>
<body>
<h1>Welcome to BowPHP</h1>
{{-- React mounts here --}}
<div id="react-app"></div>
{{-- Vue mounts here --}}
<div id="vue-app"></div>
<script type="module" src="{{ vite('js/app.js') }}"></script>
</body>
</html>
Notice we used vite(), not asset(). That choice matters β here's why.
asset() vs vite()β
BowPHP gives you two helpers, and they are not interchangeable:
asset('img/logo.png')builds a plain URL to a file inpublic/. It does not know about Vite's hashing, so reserve it for files whose names never change β a favicon, a static logo, a downloadable PDF.vite('js/app.js')reads Vite's build manifest and returns the URL of the actual hashed file (e.g./build/js/app-CBsLkjX9.js). Use it for anything Vite compiles β your JS and CSS entries.
vite(string $file, bool $absolute = false): string
Each file you reference with vite() must be declared as an entry (input)
in vite.config.js, otherwise it won't appear in the manifest and the helper
throws an exception.
/build/vite() returns URLs prefixed with /build/. Make sure your build output
(outDir in vite.config.js) and the manifest location line up with that
prefix so the browser can actually find the files.
Step 7 β Run itβ
In development, let Vite serve the assets with hot reloading β edit a component and the browser updates without a full refresh:
npm run dev
For production, compile and minify everything into public/ (this is what
generates the manifest vite() relies on):
npm run build
The vite() helper only resolves once the manifest exists. During npm run dev
the Vite server handles that for you; for a production-like test, run
npm run build first.
Going furtherβ
- Pass server data into a component by rendering a
data-*attribute or a JSON<script>tag on the mount node, then reading it inapp.jsbefore you mount β no extra API round-trip needed for the initial state. - Mix React and Vue on the same page freely: they mount into different nodes and never collide.
- Use Tailwind in components β the
tailwind.config.jscontentglobs already includeassets/js/**/*.{js,jsx,ts,tsx,vue}, so utility classes in your.jsxand.vuefiles are picked up automatically.
That's the whole loop: write a component, mount it on a node, reference the
bundle with vite(). You keep BowPHP's fast server-rendered pages and sprinkle
in exactly as much React or Vue as each screen needs. See the full
Front-End Assets documentation for Vite configuration, Tailwind
setup, and environment variables.
Is something missing?
If you run into problems with the documentation or have suggestions to improve the documentation or the project in general, please open an issue for us, or send a tweet mentioning the Twitter account @bowframework or directly on github.