Five React components mirroring the five Livewire ones, built on
@artisanpack-ui/react
and reading the JSON endpoints.
They ship as TypeScript sources rather than as a build, because they take their styling from the host application's Tailwind and daisyUI theme and so have to reach its pipeline before it compiles.
Install
php artisan vendor:publish --tag=pagespeed-insights-js
npm install @artisanpack-ui/react
That puts resources/js/{react,shared} under
resources/js/vendor/pagespeed-insights. Import from the barrel:
import {
ScoreCard,
CoreWebVitalsCard,
OpportunitiesTable,
TrendChart,
UrlManager,
} from '@/../js/vendor/pagespeed-insights/react'
export function Dashboard() {
return (
<>
<ScoreCard url="https://example.com/pricing" />
<CoreWebVitalsCard url="https://example.com/pricing" />
<OpportunitiesTable url="https://example.com/pricing" />
<TrendChart url="https://example.com/pricing" />
<UrlManager />
</>
)
}
The barrel re-exports the components, their prop types, and the whole shared fetch layer, so an application writing its own UI can use the typed clients without the components.
Props
| Prop | Components | Type | Default | Notes |
|---|---|---|---|---|
url |
all but UrlManager |
string |
— | The URL to read. Must be one this installation monitors. |
strategy |
score card, vitals, opportunities | 'mobile' | 'desktop' |
'mobile' |
The form factor. |
endpointBase |
all | string |
/pagespeed |
Set this when routes.prefix is not the default. |
fetchImpl |
all | typeof fetch |
window.fetch |
Injectable fetch, for SSR and for tests. |
csrfToken |
score card, URL manager | string | null |
<meta name="csrf-token"> |
Needed by the writing endpoints on the default web stack. |
initialMetric |
trend chart | string |
'performance' |
Which measurement is plotted first. |
initialRange |
trend chart | number |
90 |
Over how many days: 7, 30, 90, or 365. |
allowRunningTests |
score card | boolean |
true |
Whether to offer the "Run test" button. |
onResultStored |
score card | (id) => void |
— | Called with the stored result id once a queued run finishes. |
States
Each component renders the same states its Livewire counterpart does, and for the same reason: a React card, a Vue card, and a Livewire card describing the same stored row must not disagree about what it says. So "the Chrome UX Report has no data for this page" and "these numbers describe the whole site" stay two different messages, an unscored category renders as an em dash rather than a zero, and an empty opportunities list is spelled three ways — never measured, measured and clean, or the run failed.
Two things are drawn by hand rather than by the component library:
- The trend line is inline SVG. A trend carries one series per form factor,
each on its own timestamps, with nulls where a completed run lost the
measurement; the library's
Charttakes a series as a plainnumber[]against shared labels, which expresses neither — and it pulls in ApexCharts, an optional peer a host application need not have installed. A gap in the history stays a gap in the line. - The alerts pair a title with a description, which the library's
Alerttakes as children.
Keeping the panels in step
The score card is the only component that queues a run, so it is the only one
that knows when a new row lands — and it announces it, exactly as the Livewire
card dispatches pagespeed-insights:result-stored. The vitals card and the
opportunities table refresh when the announcement names their URL and their
form factor; the trend chart refreshes on either form factor, because it plots
both. A timed-out ticket announces nothing: no row appeared, so there is nothing
for the others to re-read.
The announcement lives in the shared layer rather than in either component set,
so a page can join in from anywhere — including one built with neither component
set, since it is also dispatched on window:
import { onPsiResultStored, PSI_EVENT_RESULT_STORED } from '@/../js/vendor/pagespeed-insights/shared'
// Returns an unsubscribe function.
const stop = onPsiResultStored(({ url, strategy, id }) => reloadMyPanel(url, strategy))
// Or, from a plain script on a Blade page.
window.addEventListener(PSI_EVENT_RESULT_STORED, (event) => reloadMyPanel(event.detail))
Hooks
The components are built from two hooks, both exported:
import { usePsiResource, usePsiResultStored } from '@/../js/vendor/pagespeed-insights/react'
usePsiResource(deps, load)— the load lifecycle: pending, loaded, and error states, with the in-flight request cancelled whendepschange.usePsiResultStored(handler)— subscribes to the finished-run announcement for the life of the component. It holds the handler in a ref, so it need not be memoised.
Shared fetch layer
resources/js/shared is framework-free TypeScript: one typed client per
endpoint (scores.ts, core-web-vitals.ts, opportunities.ts, trends.ts,
urls.ts, runs.ts), the request plumbing they share (client.ts), the
finished-run announcement (events.ts), and the names and colours the endpoints
deliberately do not send (labels.ts).
Both component sets consume the same modules, so both frameworks talk to one server payload rather than to two hand-written approximations of it.
Every refusal arrives as a PageSpeedInsightsError carrying the endpoint's
stable code alongside its prose message, so a client branches on the code
rather than on the wording:
import { fetchPsiScores, PageSpeedInsightsError } from '@/../js/vendor/pagespeed-insights/shared'
try {
const scores = await fetchPsiScores({ url: 'https://example.com/pricing' })
} catch (error) {
if (error instanceof PageSpeedInsightsError && 'url_not_monitored' === error.code) {
// Offer to add it to the monitored set.
}
}
The codes are listed in HTTP endpoints → Errors.
CSRF
The default route stack includes web, so POST and DELETE requests need a
CSRF token like any other session-authenticated form post. The components read
<meta name="csrf-token"> when csrfToken is not passed. If you are calling
from something with no session, move the endpoints onto a stateless stack — see
HTTP endpoints → Configuration.

