Getting started with PWA Studio Extensibility
With the 7.0 release, PWA Studio’s extensibility framework moved from theoretical to functional. Developers can now build extensions without maintaining core code, which changes how we approach Magento frontends. This post examines the technical strategies, identifies current constraints, and provides a functional extension example.
The Extensibility Framework: Capabilities and Gaps
The new APIs let you modify Buildpack, Peregrine talons, and Venia UI components. The goal is straightforward: enable changes from a storefront project or a standalone extension, avoiding duplication of the PWA-Studio source. PageBuilder integration via targets demonstrates this well—it’s essentially a Magento content plugin. It’s a solid model for your own plugins.
Replacing existing UI components, though, requires a workaround. This approach carries risk. It isn't a compound change. If two extensions attempt to replace the same file, you’ll get errors. The Core Team currently avoids supporting a default fallback structure. We think a method for plugins to register new components into existing UI structures would be better. PWA Studio packages could declare which components are safe to replace, making them replaceable. A dependable replacement strategy is necessary for building standalone marketplace extensions vendors can trust.
Available Extension Points
Venia UI currently permits adding new routes or creating richContentRenderer instances. The richContentRenderers target is what integrates the Magento Page Builder. You could use a similar pattern for a CMS-based blog plugin.
Peregrine allows wrapping any individual talon when it's invoked. You can modify its behavior and output. This uses an interceptor pattern, similar to Magento's around plugins.
// intercept.js - Example of wrapping a Peregrine talon
targets.of('@magento/peregrine').talons.tap(talons => {
talons.App.useApp.wrapWith('./wrappers/logWrapper');
});
// wrappers/logWrapper.js
export default function wrapUseApp(originalFunction) {
return function useApp(...args) {
console.log('LogWrapper: Calling useApp with args:', args);
const result = originalFunction(...args);
console.log('LogWrapper: useApp result:', result);
return result;
};
}
Buildpack extension points are built on Webpack Compiler Hooks. This API is robust and widely documented, common for building advanced Webpack plugins.
Building an Extension: A Top Bar Notification Component
Let’s create a real extension. The requirement is a top bar informing customers about holiday delays, styled with brand colors.
1. Initialize a PWA Studio Project
If you don't have one, create it.
yarn create @magento/pwa
cd your-project
yarn install
yarn buildpack create-custom-origin ./
2. Create the Extension Module
Structure your extension as a separate module.
mkdir -p vendor-name/topbar-notification
cd vendor-name/topbar-notification
3. Define the Extension's Package
The package.json sets up dependencies and points Buildpack to your intercept file.
{
"name": "@vendor-name/topbar-notification",
"version": "1.0.0",
"description": "Displays a notification bar in the PWA header.",
"license": "MIT",
"main": "./src/components/TopBar/index.js",
"pwa-studio": {
"targets": {
"intercept": "./src/intercept.js"
}
},
"peerDependencies": {
"@magento/pwa-buildpack": "*",
"@magento/venia-ui": "*",
"react": "^16.14.0 || ^17.0.0",
"react-router-dom": "^5.2.0"
}
}
4. Create the Intercept File
This file taps into Buildpack targets to enable ES Modules and apply our component overrides.
// src/intercept.js
const ModuleOverridePlugin = require('./webpack/ModuleOverridePlugin');
// Map core components to our custom versions
const componentOverrides = {
'@magento/venia-ui/lib/components/Main/main.js': './src/overrides/Main/main.js'
};
module.exports = function(targets) {
// Enable ES Modules and CSS Modules for this extension
targets.of('@magento/pwa-buildpack').specialFeatures.tap(features => {
features[targets.name] = { esModules: true, cssModules: true };
});
// Apply our custom Webpack plugin to handle component overrides
targets.of('@magento/pwa-buildpack').webpackCompiler.tap(compiler => {
new ModuleOverridePlugin(componentOverrides).apply(compiler);
});
};
5. Implement a Custom Webpack Plugin
While Webpack's NormalModuleReplacementPlugin works, a custom plugin offers clearer mapping.
// src/webpack/ModuleOverridePlugin.js
const path = require('path');
const glob = require('glob');
module.exports = class ModuleOverridePlugin {
constructor(overrideMap) {
this.name = 'ModuleOverridePlugin';
this.overrideMap = overrideMap || {};
}
// Safely resolve a module path
safeResolve(moduleId, options) {
try {
return require.resolve(moduleId, options);
} catch (err) {
return undefined;
}
}
// Resolve the actual file path, accounting for extensions
resolveFilePath(basePath, request) {
const pathWithoutExt = path.resolve(basePath, request);
const matches = glob.sync(`${pathWithoutExt}@(|.*)`);
if (matches.length === 0) {
throw new Error(`File not found: ${pathWithoutExt}`);
}
if (matches.length > 1) {
throw new Error(`Multiple files match: ${pathWithoutExt}`);
}
return require.resolve(matches[0]);
}
// Transform the user's override map into absolute paths
buildPathMap(context, map) {
return Object.keys(map).reduce((acc, originalPath) => {
const absOriginal = require.resolve(originalPath);
const overridePath = this.safeResolve(map[originalPath]) || this.resolveFilePath(context, map[originalPath]);
acc[absOriginal] = overridePath;
return acc;
}, {});
}
apply(compiler) {
if (Object.keys(this.overrideMap).length === 0) return;
const resolvedMap = this.buildPathMap(compiler.context, this.overrideMap);
compiler.hooks.normalModuleFactory.tap(this.name, (normalModuleFactory) => {
normalModuleFactory.hooks.beforeResolve.tap(this.name, (resolveData) => {
if (!resolveData) return;
const requestPath = this.safeResolve(resolveData.request, { paths: [resolveData.context] });
if (requestPath && resolvedMap[requestPath]) {
resolveData.request = resolvedMap[requestPath];
}
return resolveData;
});
});
}
};
6. Build the TopBar Component
First, the styles.
/* src/components/TopBar/topbar.css */
.root {
background-color: rgb(var(--venia-teal));
padding: 0.75rem 1rem;
color: rgb(255, 255, 255);
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
font-size: 0.875rem;
line-height: 1.25;
}
.message {
justify-self: start;
}
.actions {
justify-self: end;
}
.actionButton {
color: inherit;
margin-left: 1rem;
padding: 0.5rem 1.5rem;
border: 1px solid currentColor;
border-radius: 2px;
background: transparent;
cursor: pointer;
font-size: 0.8125rem;
}
Then, the React component.
// src/components/TopBar/topbar.js
import React from 'react';
import { mergeClasses } from '@magento/venia-ui/lib/classify';
import Button from '@magento/venia-ui/lib/components/Button';
import defaultClasses from './topbar.css';
const TopBar = () => {
const classes = mergeClasses(defaultClasses);
const notificationText = 'Holiday notice: Delivery times may be extended.';
return (
<div className={classes.root} role="region" aria-label="Site notification">
<span className={classes.message}>{notificationText}</span>
<div className={classes.actions}>
<Button
className={classes.actionButton}
onClick={() => window.open('/delivery-info', '_blank')}
priority="low"
type="button"
>
View Details
</Button>
</div>
</div>
);
};
export default TopBar;
// src/components/TopBar/index.js
export { default } from './topbar';
7. Override the Main Component
Copy the original Main component and inject the TopBar.
mkdir -p src/overrides/Main
cp node_modules/@magento/venia-ui/lib/components/Main/main.js src/overrides/Main/main.js
Modify the copied file.
// src/overrides/Main/main.js
import React from 'react';
import { bool, shape, string } from 'prop-types';
import { useScrollLock } from '@magento/peregrine';
import { mergeClasses } from '@magento/venia-ui/lib/classify';
import Footer from '@magento/venia-ui/lib/components/Footer';
import Header from '@magento/venia-ui/lib/components/Header';
import TopBar from '@vendor-name/topbar-notification/src/components/TopBar'; // Your component
import defaultClasses from '@magento/venia-ui/lib/components/Main/main.css';
const Main = props => {
const { children, isMasked } = props;
const classes = mergeClasses(defaultClasses, props.classes);
const rootClass = isMasked ? classes.root_masked : classes.root;
const pageClass = isMasked ? classes.page_masked : classes.page;
useScrollLock(isMasked);
return (
<main className={rootClass}>
<TopBar />
<Header />
<div className={pageClass}>{children}</div>
<Footer />
</main>
);
};
Main.propTypes = {
classes: shape({
page: string,
page_masked: string,
root: string,
root_masked: string
}),
isMasked: bool
};
export default Main;
8. Integrate the Extension for Local Development
In your main PWA project's package.json, link the local module.
"devDependencies": {
"@vendor-name/topbar-notification": "file:../vendor-name/topbar-notification"
}
Then run yarn install --force in the PWA project root.
Final Thoughts and Further Reading
This works. You can extend PWA Studio now. But the replacement strategy for UI components feels brittle, honestly. We need a more declarative API where extensions can add to components, not just swap files. The community is working on it.
For production, you'd publish your extension to a registry and install it via yarn add. The Buildpack targets system is powerful, akin to Magento's backend dependency injection. It’s the right foundation.
Check the official docs for updates:
The framework is maturing. According to our work on several projects, it’s viable for bespoke storefront features. The next step is building a real ecosystem. Maybe that’s already happening.