HubSpot's current CPQ quote system is not the old HubL quote-template system with a new name. It uses React-based coded modules that HubSpot renders inside a controlled quote layout. If you already understand coded email templates, website themes, or drag-and-drop CMS modules, some ideas will feel familiar: developers create the building blocks and marketers or sales teams arrange and configure them. The implementation model, however, is different enough that treating it like a normal page template causes avoidable problems.
This guide explains that implementation model, shows the patterns behind complex quotes, and identifies the places where current platform behavior requires care.
You can follow the guide with your own project or use the free Works by Design Quote Module Collection as a working reference. The collection is an example implementation, not a required framework.
What this guide adds to HubSpot's quickstart#
HubSpot's Create custom quote modules page is the authoritative quickstart. It gets one example module uploaded and explains the essential exports, targeted data, Islands, editor rendering, and print mode. This guide starts there and adds what developers need for a complete buyer-facing implementation:
- an exact first-run path from the starter or downloadable collection;
- the relationship between the quote template, module, HubL bridge, SSR component, Island, and serverless function;
- real quote-context and line-item patterns;
- custom line-item presentation without breaking HubSpot's native editing requirements;
- PDF-safe interactive components;
- SDK actions, serverless security, and quote lifecycle constraints;
- the difference between supported APIs, observed behavior, and Works by Design conventions;
- a release matrix for commercial accuracy, accessibility, privacy, and published-quote snapshots.
Use HubSpot's quickstart for the shortest official path. Use this guide when the first example works and you need to build the actual quote.
What you can build#
A coded CPQ quote can be much more than a price table. A well-designed quote may include:
- a branded cover and introduction;
- sticky navigation between quote sections;
- a completely custom line-item table;
- recurring, one-time, and tiered-pricing presentation;
- totals, discounts, fees, taxes, and terms;
- a mutual action plan or implementation timeline;
- team profiles, proof points, FAQs, and resources;
- interactive accordions or selectors;
- buttons that invoke supported quote acceptance or payment actions;
- serverless-backed interactions that safely call HubSpot or third-party APIs;
- a PDF layout that remains complete when JavaScript is unavailable.
The key is to design for every runtime in which the quote appears: the template editor, module preview, published web quote, and generated PDF.
1. Start with the right mental model#
Legacy quotes versus CPQ quotes#
In a legacy coded quote, the developer commonly owns a HubL template and embeds modules in it. In the current CPQ system, HubSpot owns the outer quote application and drag-and-drop area. You build React modules that are made available to that quote editor.
The practical differences are:
| Area | Legacy coded quote | Current CPQ coded module |
|---|---|---|
| Primary rendering | HubL template | React module rendered by HubSpot |
| Data access | Template context directly in HubL | HubL data bridge into React props |
| Interactivity | Browser JavaScript in the template/module | React Islands and quote SDK hooks |
| Layout ownership | Developer-controlled template | HubSpot-controlled quote blueprint and module area |
| Module placement | Template-defined or drag-and-drop | Quote template editor |
| PDF strategy | Template print CSS | SSR-safe markup plus print CSS and PDF detection |
Do not begin by trying to recreate the entire quote application. Begin by deciding which sections should be separate modules, which data each module needs, and what the PDF must contain.
The four layers of a quote module#
Every useful quote module has four conceptual layers:
- Configuration — fields the editor can change.
- Data bridge — HubL that selects quote data and passes it to React.
- Presentation — the React component and its styles.
- Interaction — optional client-side Islands, SDK actions, or serverless calls.
Keeping those layers separate makes the module easier to test and reduces accidental exposure of quote data.
2. Prerequisites and project shape#
You need:
- a HubSpot account with Revenue Hub Professional or Enterprise;
- the current HubSpot CLI installed and authenticated;
- Node.js and npm;
- working knowledge of React and TypeScript;
- a test account and non-production quote data.
At the time of writing, existing HubSpot CLI scaffolding commands do not create a quote-module project. Start from HubSpot's quote-dev-starter or from the Works by Design Quote Module Collection. Do not create a normal CMS React project and assume that adding QUOTE metadata will turn it into the same project.
Choose a starting path#
Learning or building one module: download HubSpot's starter, run npm install from its root, and change the example incrementally. The starter's post-install process installs the nested CMS asset dependencies.
npm install
npm start
The starter also contains ConditionalTermsModule and RequiredAgreementModule. Read those after the base example: they demonstrate line-item-driven content and gating quote acceptance. They are valuable examples, but they are not substitutes for understanding PDF output, custom line-item presentation, serverless persistence, and published-quote lifecycle behavior.
Deploying a complete reference set: download and unzip the Works by Design collection, open a terminal in the extracted project root—the folder containing hsproject.json—and run:
hs project upload
The first upload prompts you to create/name the HubSpot project. Do not name it cpq-theme; HubSpot reserves that name. A successful build makes the modules available to the quote template editor. Install npm dependencies only when developing or rebuilding locally; follow the package scripts in the download because the root and CMS asset bundle have separate dependency boundaries. If your download contains a serverless source that has been changed, run its documented build step before upload so the bundled entrypoint is current.
The actual project boundaries#
A current quote-module project separates CMS assets from the optional private app and serverless functions:
hsproject.json # project name, srcDir, platformVersion
src/
cms-assets/
cms-asset-hsmeta.json
my-react-assets/
Globals.d.ts # QuoteTemplateContext declarations
package.json
tsconfig.json
components/
modules/
QuoteIntroduction/
index.tsx # Component, fields, meta, hublDataTemplate
islands/
InteractivePanel.tsx
shared/
quoteTheme.ts # optional shared presentation tokens
app/ # only needed for app/serverless capabilities
app-hsmeta.json
functions/
quote-action.js
quote-action-hsmeta.json
The folder names can differ, but the boundaries matter. React quote modules are CMS assets. Serverless functions belong to the app portion of the same developer project. Globals.d.ts can use the QuoteTemplateContext type from @hubspot/quote-dev-sdk to improve compile-time safety.
For the current generation of modules, use the platform version required by HubSpot's quote-module documentation and your account. The examples in this guide assume the 2026.03 React module model. Confirm the supported version before starting a new project because platform versions evolve.
Useful dependencies may include:
{
"dependencies": {
"@hubspot/cms-components": "latest",
"@hubspot/quote-dev-sdk": "latest",
"react": "latest"
}
}
Pin versions according to your release process rather than copying latest into a long-lived production project without review.
3. Build the smallest complete module#
A quote module normally exports four things:
Component: the React component HubSpot renders;fields: the configuration fields shown to the editor;meta: module name and availability metadata;hublDataTemplate: the HubL-to-React data bridge.
import { ModuleFields, TextField } from '@hubspot/cms-components/fields';
type QuoteIntroductionProps = {
fieldValues: {
heading?: string;
};
hublData?: {
buyerName?: string;
quoteTitle?: string;
};
};
export function Component({ fieldValues, hublData }: QuoteIntroductionProps) {
const heading = fieldValues.heading || hublData?.quoteTitle || 'Your proposal';
const buyerName = hublData?.buyerName;
return (
<section aria-labelledby="quote-introduction-heading">
<h2 id="quote-introduction-heading">{heading}</h2>
{buyerName && <p>Prepared for {buyerName}</p>}
</section>
);
}
export const fields = (
<ModuleFields>
<TextField
name="heading"
label="Heading"
default="Your proposal"
/>
</ModuleFields>
);
export const meta = {
label: 'Quote introduction',
content_types: ['QUOTE', 'QUOTE_BLUEPRINT'],
};
export const hublDataTemplate = `
{% if quoteTemplateContext.buyerContacts|length > 0 %}
{% set buyer = quoteTemplateContext.buyerContacts[0] %}
{% endif %}
{% set hublData = {
"buyerName": buyer.firstname if buyer else null,
"quoteTitle": quoteTemplateContext.quote.hs_title,
"isQuoteBlueprint": isQuoteBlueprint,
"isInEditor": is_in_editor,
"isInPreviewer": is_in_previewer
} %}
`;
This is the minimum useful pattern: the HubL string assigns a value to hublData; HubSpot passes that object to Component. It does not print a JSON document. The precise context paths available to your account and template can vary. Inspect the actual quote context and add null guards instead of assuming every association exists.
Make the module available in both contexts#
For most reusable quote modules, include both content types:
content_types: ['QUOTE', 'QUOTE_BLUEPRINT']
QUOTE_BLUEPRINT makes the module available while building a quote template. QUOTE covers quote rendering. Omitting one often produces the confusing result that a valid module cannot be found where expected.
4. Configuration fields that behave predictably#
Field values arrive separately from quote data. Keep the distinction explicit:
fieldValuesare selected by the template editor;hublDatais data deliberately passed throughhublDataTemplate;- runtime props tell you about the current rendering context.
Common field components include text, rich text, number, choice, boolean, color, groups, and repeaters. Use editor labels that describe the outcome, not the implementation.
import {
BooleanField,
ChoiceField,
ColorField,
FieldGroup,
ModuleFields,
NumberField,
RichTextField,
TextField,
} from '@hubspot/cms-components/fields';
export const fields = (
<ModuleFields>
<TextField name="heading" label="Heading" default="Why this solution" />
<RichTextField name="body" label="Body" />
<BooleanField name="showEyebrow" label="Show eyebrow" default={true} />
<ChoiceField
name="alignment"
label="Text alignment"
choices={[
['left', 'Left'],
['center', 'Centre'],
]}
default="left"
/>
<FieldGroup name="style" label="Style" tab="STYLE">
<ColorField name="accent" label="Accent colour" />
<NumberField name="spacing" label="Section spacing" default={32} />
</FieldGroup>
</ModuleFields>
);
Normalise values at the boundary#
Do not assume all editor values have the ideal JavaScript type in every runtime. A boolean-like value may need normalising:
function asBoolean(value: unknown, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'string') return value.toLowerCase() === 'true';
return Boolean(value);
}
Colour fields commonly produce an object, so read its colour value defensively:
const accent = fieldValues.style?.accent?.color || '#ff5c35';
Number-like values passed through HubL may arrive as strings. Convert only when you need arithmetic:
const position = Number.parseInt(String(rawPosition ?? 0), 10);
Repeaters and advanced fields#
Repeaters are ideal for FAQs, milestones, resources, proof points, and team members. Confirm the current CMS React fields support before choosing JSX or fields.json; not every familiar legacy-module field pattern maps directly to a React field component. Keep defaults small, make empty states intentional, and test item reordering in the editor. See HubSpot's module and theme fields reference for the current field contract.
Never use field data as trusted HTML, URLs, identifiers, or API input without the appropriate escaping and validation.
5. The HubL data bridge#
React modules do not automatically receive the entire quote template context. hublDataTemplate is the controlled bridge between HubSpot's server-rendered quote data and your component.
That bridge is one of the most important design decisions in the module:
- pass only the values the component needs;
- serialise values correctly;
- guard missing associations and properties;
- keep secrets and access tokens out of the output;
- prefer simple JSON-shaped data over exposing a large raw object.
Choose the narrowest suitable data source:
| Source | Use it for | Important constraint |
|---|---|---|
quoteTemplateContext | Quote, deal, buyer, signer, and line-item values already in the snapshot | Select only the properties the component needs |
crm_object() / crm_associations() | Missing CRM properties available during server rendering | Guard IDs and request an explicit property list |
| HubDB | Managed content such as legal text or regional guidance | Requires the applicable Content Hub subscription |
| Serverless function | Secrets, external APIs, live reads, or controlled writes | Browser input is untrusted; validate and authorise server-side |
export const hublDataTemplate = `
{% set hublData = {
"quoteId": quoteTemplateContext.quote.hs_object_id,
"quoteTitle": quoteTemplateContext.quote.hs_title,
"currency": quoteTemplateContext.quote.hs_currency,
"locale": quoteTemplateContext.quote.hs_locale,
"dealName": quoteTemplateContext.deal.dealname if quoteTemplateContext.deal else null,
"lineItems": quoteTemplateContext.lineItems,
"isQuoteBlueprint": isQuoteBlueprint,
"isInEditor": is_in_editor,
"isInPreviewer": is_in_previewer,
"isPdf": true if quoteTemplateContext.quote.hs_pdf_generation_status == "PDF_GENERATING" else false
} %}
`;
The names above are representative of the current typed context and the public collection, but treat Globals.d.ts and a real test quote as the source of truth for your project version. If data is not in quoteTemplateContext, fetch only the required properties with supported HubL functions such as crm_object() or crm_associations(). Do not use that as an excuse to fetch an entire CRM record.
Useful quote-context areas#
The quote context commonly includes some combination of:
- quote properties, including title, status, currency, dates, totals, terms, and identifiers;
- an associated deal;
- associated contacts or buyers;
- associated company details;
- quote signers;
- line items, pricing, quantities, discounts, billing periods, and positions;
- template, brand, language, and rendering-state information.
Start discovery with these current top-level paths:
| Need | HubL path | Guard/notes |
|---|---|---|
| Quote properties | quoteTemplateContext.quote | Select individual properties such as hs_title, hs_currency, and hs_locale |
| Deal | quoteTemplateContext.deal | May be null; guard before property access |
| Buyer company | quoteTemplateContext.buyerCompany | May be null |
| Buyer contacts | quoteTemplateContext.buyerContacts | Array; check length before choosing the first contact |
| Line items | quoteTemplateContext.lineItems | Array; hs_position_on_quote can arrive as a string |
| Signers | quoteTemplateContext.signers | Array |
| Counter-signers | quoteTemplateContext.counterSigners | Array |
| Blueprint state | isQuoteBlueprint | Use fictional fallbacks only in the template/blueprint |
| Editor state | is_in_editor | Use to suppress buyer-only interaction |
| Previewer state | is_in_previewer | Suppress write-capable interaction here too |
Use the QuoteTemplateContext type for autocomplete, but do not assume the type package enumerates every runtime or portal-specific property. Confirm required custom properties on a real quote. Some custom properties can already be present on context objects; only fall back to crm_object()/crm_associations() when the value is genuinely absent.
Names and availability are not guaranteed merely because a similarly named CRM property exists. Build a temporary debug module during development to inspect the actual data available, then remove or restrict it before release: raw quote context can contain personal, commercial, or internal information.
Data minimisation is also a performance feature#
The data bridge becomes part of the rendered module input. Passing the full quote context can increase payload size, make hydration slower, and expose information to browser code that the component never uses. Select the smallest stable data contract.
6. Understand every rendering context#
The same module behaves differently depending on where it is rendered.
| Context | Quote data | Editor controls | Browser JavaScript | Main concern |
|---|---|---|---|---|
| Module preview | Often fallback or incomplete | No | May run | Useful empty/fallback state |
| Blueprint/template editor | May use sample data | Yes | Usually | Editing experience |
| Published quote | Real quote snapshot | No | Yes | Buyer experience and accessibility |
| PDF generation | Real quote snapshot | No | Do not depend on it | Complete static output |
Use runtime/editor props exposed by the platform to distinguish authoring controls from buyer-facing content. Never show editor-only actions to a buyer merely because quote data is present.
Fallback data is useful in the blueprint editor, but it should be obviously fictional and should not leak into a real quote when an association is missing. Prefer a meaningful empty state such as “Add line items to preview this section” over invented commercial totals.
7. React Islands: add interaction without breaking server rendering#
The module component should produce useful server-rendered HTML. Put browser-only state and effects inside an Island. An Island is a separate file imported with ?island; a normal locally declared React component is not enough.
Create islands/AccordionIsland.tsx:
import { useState } from 'react';
type Item = { title: string; bodyHtml: string };
export default function AccordionIsland({ items }: { items: Item[] }) {
const [open, setOpen] = useState<Record<number, boolean>>({});
return (
<div>
{items.map((item, index) => (
<section key={item.title}>
<button
type="button"
aria-expanded={Boolean(open[index])}
aria-controls={`answer-${index}`}
onClick={() => setOpen((previous) => ({
...previous,
[index]: !previous[index],
}))}
>
{item.title}
</button>
<div
id={`answer-${index}`}
className="quote-accordion-body"
style={{ display: open[index] ? 'block' : 'none' }}
dangerouslySetInnerHTML={{ __html: item.bodyHtml }}
/>
</section>
))}
</div>
);
}
Then import it from the module's index.tsx:
import { Island } from '@hubspot/cms-components';
// @ts-expect-error — the HubSpot build handles the ?island suffix
import AccordionIsland from './islands/AccordionIsland.tsx?island';
type AccordionItem = { title: string; bodyHtml: string };
type Props = {
fieldValues: { heading?: string; items?: AccordionItem[] };
hublData?: {
isInEditor?: boolean;
isInPreviewer?: boolean;
isPdf?: boolean;
};
};
export function Component(props: Props) {
const items = props.fieldValues.items || [];
const { isInEditor, isInPreviewer, isPdf } = props.hublData || {};
// Never allow a write-capable Island to run in editor/previewer.
// For a read-only accordion, the static branch also avoids editor reloads.
if (isInEditor || isInPreviewer || isPdf) {
return (
<section>
<h2>{props.fieldValues.heading}</h2>
{items.map((item) => (
<section key={item.title}>
<h3>{item.title}</h3>
<div dangerouslySetInnerHTML={{ __html: item.bodyHtml }} />
</section>
))}
</section>
);
}
return (
<section>
<h2>{props.fieldValues.heading}</h2>
<Island module={AccordionIsland} items={items} hydrateOn="load" />
</section>
);
}
For a simple read-only Island, hydrateOn="visible" or "idle" may reduce initial JavaScript work. Use "load" for controls that must work immediately. See HubSpot's Islands reference for the current import contract and hydration options.
Island rules worth treating as architecture#
- Do not access
window,document,localStorage, or browser-only APIs during server rendering. - Use
useEffectfor browser-only work. - Pass serialisable props only.
- Do not pass access tokens, secrets, or unnecessary private quote data to an Island.
- Provide an explicit static/PDF/editor branch for important content.
- Give interactive controls accessible names, focus states, and correct ARIA state.
- Test hydration errors in the browser console.
For cross-module coordination, browser custom events are a simple option when two Islands need to communicate:
window.dispatchEvent(new CustomEvent('quote:options-changed', {
detail: { selectedIds },
}));
Listeners should be registered and removed inside useEffect. Namescape event names and document their payload. Remember that custom events affect the browser experience only; the PDF must not depend on them.
8. Design for PDF from the first commit#
A quote is both a web experience and a document. PDF generation may not execute your client-side interaction as a buyer's browser would. If content matters contractually or commercially, it must exist in the server-rendered markup.
The three-layer PDF contract#
The Quote Module Collection uses a robust three-layer pattern for accordions:
- Detect PDF/print rendering where the platform exposes that state and render all panels expanded.
- Keep panel bodies in the DOM; toggle visibility rather than conditionally creating the content only after a click.
- Add print CSS that forces every panel visible.
@media print {
.accordionPanel {
display: block !important;
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
.accordionToggle {
display: none !important;
}
}
Use both of the currently available signals:
- HubSpot's public contract adds
?print=trueduring print/PDF rendering. Inside an Island, read it withusePageUrl()and suppress web-only behavior. - The collection also detects the observed server-side state
quoteTemplateContext.quote.hs_pdf_generation_status == "PDF_GENERATING"inhublDataTemplate, allowing the outer component to choose expanded static markup before hydration.
import { usePageUrl } from '@hubspot/cms-components';
export default function QuoteNavigationIsland() {
const url = usePageUrl();
if (url.searchParams.get('print') === 'true') return null;
return <nav>{/* web-only navigation */}</nav>;
}
Keep the @media print fallback even when both signals work. A buyer can print the web page directly, and important document content should not rely on one runtime flag.
PDF-safe design checklist#
- All important content exists without a click.
- Accordions expand.
- Carousels show all essential slides or a deliberate static alternative.
- Video has a useful title, thumbnail, and link.
- Navigation and editor controls are removed.
- Background colours and text remain legible when printed.
- Long tables can wrap and continue across pages.
- Headings do not become isolated at page bottoms where CSS can prevent it.
- URLs remain understandable if the PDF viewer does not preserve the click target.
- Totals and legal terms are never produced only by client-side calculations.
Always generate an actual PDF during testing. Browser print preview is useful, but it is not proof that HubSpot's PDF renderer will paginate identically.
9. Build a custom line-item module correctly#
A custom line-item section is usually where a complex quote becomes valuable and where mistakes become expensive.
Do not remove HubSpot's native Line items module. HubSpot currently requires the native module to remain on the quote because it owns line-item editing. Hide it in the presentation when using your custom display. The same limitation applies when replacing the native Parties presentation. A custom module reads and presents the data; it does not automatically replace the native editing capability.
Do not recompute HubSpot's commercial truth casually#
Use HubSpot-provided calculated values when available. In particular, display the line item's provided amount rather than assuming that quantity × unit price reproduces every discount, billing, or tiered-pricing rule.
A resilient presentation model might include:
type QuoteLineItem = {
id?: string;
name?: string;
description?: string;
sku?: string;
quantity?: string | number;
price?: string | number;
amount?: string | number;
hs_position_on_quote?: string | number;
hs_recurring_billing_period?: string;
hs_pricing_model?: string;
hs_tiered_pricing_table?: unknown;
};
Sort positions numerically because the context may serialise them as strings:
const sortedItems = [...items].sort((a, b) => {
return Number(a.hs_position_on_quote || 0) - Number(b.hs_position_on_quote || 0);
});
Format money with Intl.NumberFormat and the quote's currency. Keep the raw numeric value separate from its display string.
function formatMoney(value: unknown, currency: string, locale = 'en-US') {
const amount = Number(value);
if (!Number.isFinite(amount)) return '';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount);
}
Tiered pricing is a presentation problem and a totals problem#
Tiered-pricing metadata may explain the rate bands, while the line-item amount represents HubSpot's calculated result. Show both when useful:
- the table of tiers so the buyer understands how pricing works;
- HubSpot's amount as the commercial total.
Do not replace the provided amount with a browser-side calculation unless you own and test every pricing rule. Validate boundary quantities, multiple currencies, percentage discounts, fixed discounts, and recurring frequencies.
Conditional content should be derived from stable properties#
The starter's conditional-terms module is a useful pattern: derive presentation from line-item properties rather than asking the quote author to remember which legal section to enable.
const hasRecurring = items.some((item) => Boolean(item.recurringbillingfrequency));
const hasOneTime = items.some((item) => !item.recurringbillingfrequency);
const sections = [
GENERAL_TERMS,
...(hasRecurring ? [SUBSCRIPTION_TERMS] : []),
...(hasOneTime ? [ONE_TIME_TERMS] : []),
];
Use legal-approved content, define the property-to-clause rules outside the rendering loop, and test quotes containing both recurring and one-time items. If the rules depend on country, product family, or custom properties, document the exact precedence and missing-data behavior.
Filtering and grouping#
Some implementations use SKU prefixes or custom properties to group items into categories or suppress operational items. That can be effective, but document the convention and make it configurable where possible. A hard-coded prefix becomes hidden business logic if sales teams do not know it exists.
Direct links into the line-item editor#
The Quote Module Collection includes editor-only buttons that open line-item editing actions through HubSpot's internal application event bus. This is useful, but it is not a documented public integration contract.
Treat this pattern as observed and unsupported:
- show the buttons only in the editor;
- isolate the event-bus adapter in one small file;
- feature-detect the required object and action;
- fail silently with a normal instructional fallback;
- never make quote rendering depend on it;
- regression-test it after HubSpot editor releases.
If HubSpot publishes a supported editor navigation API, replace the internal adapter.
10. Styling and brand architecture#
Avoid designing each section as an unrelated card. A quote feels more credible when its modules share typography, spacing, colour, borders, and document width.
Prefer native quote design tokens#
HubSpot exposes quote-level CSS custom properties in the rendered experience. Use supported native variables where possible, with a fallback:
.section {
color: var(--hsQuotes--text-color, #213343);
background: var(--hsQuotes--background-color, #ffffff);
font-family: var(--hsQuotes--font-family, Arial, sans-serif);
}
.button {
background: var(--hsQuotes--primary-color, #ff5c35);
}
Inspect the current quote output to confirm the variable names HubSpot emits. Do not build the whole theme around an undocumented variable without a fallback.
Brand settings in quote context#
In quote rendering, brand_settings can be a more useful source than assumptions borrowed from normal website page context. site_settings may be empty or incomplete. Pass only the brand values you need through the data bridge, then fall back to module fields and safe defaults.
A practical priority order is:
- an explicit per-module override;
- quote or brand settings exposed in context;
- shared quote CSS variables;
- a conservative default.
Share tokens, not hidden coupling#
A shared TypeScript theme file can keep spacing, radii, typography, and colour fallbacks consistent across modules. Do not make a module fail if another visual module is absent. Each section should be independently renderable.
11. Navigation and document structure#
A sticky navigation module can make a long web quote feel like a small microsite. The Quote Module Collection demonstrates DOM discovery: modules expose stable section identifiers and the navigation Island finds the sections that actually exist.
If you use this approach:
- assign semantic, stable anchors to sections;
- do not infer navigation labels from fragile CSS classes;
- account for a sticky header when scrolling;
- update the active state accessibly;
- hide navigation from PDF output;
- preserve a logical heading order even when modules are rearranged.
The quote should still read correctly from top to bottom without JavaScript or navigation.
12. Acceptance, signature, and payment actions#
The quote development SDK exposes supported hooks for quote actions such as acceptance and payment. Use those hooks instead of constructing private URLs or imitating HubSpot UI.
Install the SDK as a runtime dependency when an Island calls its hooks. A useful acknowledgement pattern is:
// islands/RequiredAgreement.tsx
import { useEffect, useState } from 'react';
import { useQuoteAcceptance } from '@hubspot/quote-dev-sdk';
export default function RequiredAgreement({ reason }: { reason: string }) {
const { loading, error, data, control } = useQuoteAcceptance();
const [agreed, setAgreed] = useState(false);
useEffect(() => {
if (loading || error || data?.accepted) return;
if (agreed) control.enable();
else control.disable({ reason });
}, [agreed, loading, error, data?.accepted, control, reason]);
if (loading) return <p>Loading acceptance status…</p>;
if (error || !data) return <p>Acceptance controls are unavailable.</p>;
return (
<label>
<input
type="checkbox"
checked={agreed || data.accepted}
disabled={data.accepted}
onChange={(event) => setAgreed(event.target.checked)}
/>
I have reviewed and agree to the additional terms.
</label>
);
}
Render that file through an Island only on a live quote; use a static representation in the editor, previewer, and PDF. The SDK also provides useQuotePayment() with analogous loading, data, error, and control behavior. Acceptance cannot be disabled after the quote is accepted or when the method is print-and-sign; payment cannot be disabled after it reaches a paid or processing state. Let the SDK guards win.
Runtime rules:
- invoke actions only on a live quote where the capability is available;
- hide or disable controls in the editor and preview;
- check whether the action is enabled before presenting it;
- provide loading, success, and error states;
- prevent repeated submission;
- do not claim that a button completed acceptance until the SDK confirms it;
- verify the resulting quote status and signed document.
Disabling HubSpot's acceptance control is not the same as persisting an auditable consent record. If the acknowledgement itself must be recorded, write it through a validated serverless function and define what happens after refresh or on a second device.
Acceptance and payment are contractual workflows. Keep decorative interaction separate from action state, and test with the exact quote approval, signature, and payment configuration used by the account.
13. Serverless functions: the safe integration boundary#
Browser code must not contain a HubSpot private-app token or third-party secret. If a module needs privileged data or must update CRM records, call a project serverless function and let that function call the external API.
Quote Island
|
| fetch('/hs/serverless/quote-action', ...)
v
HubSpot serverless function
|
| authenticated API request
v
HubSpot CRM or third-party service
Client call#
async function runQuoteAction(payload: unknown) {
const response = await fetch('/hs/serverless/quote-action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(result?.message || 'The quote action failed');
}
return result;
}
Serverless handler#
const hubspot = require('@hubspot/api-client');
const reply = (statusCode, body) => ({
statusCode,
body: JSON.stringify(body),
});
exports.main = async (context = {}) => {
try {
const token = process.env.PRIVATE_APP_ACCESS_TOKEN;
if (!token) {
return reply(500, { message: 'Server configuration error' });
}
const body = typeof context.body === 'string'
? JSON.parse(context.body)
: (context.body || {});
const quoteId = String(body.quoteId || '');
if (!/^\d+$/.test(quoteId)) {
return reply(400, { message: 'Invalid quote ID' });
}
const client = new hubspot.Client({ accessToken: token });
const quote = await client.crm.quotes.basicApi.getById(
quoteId,
['hs_title', 'hs_status']
);
return reply(200, {
id: quote.id,
title: quote.properties.hs_title,
status: quote.properties.hs_status,
});
} catch (error) {
console.error('quote-action failed', error);
return reply(500, { message: 'The quote action failed' });
}
};
In the project app-function runtime tested by Works by Design, the body needed to be stringified; returning a plain object produced a successful response with an empty body. HubSpot also documents serverless variants that accept a body object, so treat this as runtime-specific and verify it for the project metadata/platform version you deploy.
Project configuration matters#
The app metadata must permit every external hostname the function calls, using the correct permission category for each request type. The package must include runtime dependencies such as @hubspot/api-client. HubSpot injects the app's private access token into the supported serverless environment; do not copy it into module fields, source files, client props, or a public repository.
An app-function metadata file connects the public endpoint to the bundled handler:
{
"uid": "quote_action",
"type": "app-function",
"config": {
"entrypoint": "/app/functions/quote-actionBundled.js",
"endpoint": {
"path": "quote-action",
"methods": ["POST"]
},
"secretKeys": []
}
}
The surrounding app-hsmeta.json must declare the app's required HubSpot scopes and every permitted external URL. Bundle dependencies into the configured entrypoint when required by the runtime. After the first successful deployment, install the private app portion in the target account; without that installation, PRIVATE_APP_ACCESS_TOKEN is not injected. A function that deploys is not necessarily a function that is installed or authorised.
Serverless security checklist#
- Validate every identifier and enum from the browser.
- Authorise the requested quote rather than trusting a submitted quote ID.
- Re-fetch prices and state server-side before mutations.
- Use allowlists for properties and actions.
- Make writes idempotent where a retry could occur.
- Rate-limit abuse-prone endpoints when the platform allows it.
- Return generic errors to the buyer and detailed logs to operators.
- Avoid logging tokens, signatures, personal data, or full quote payloads.
- Restrict outbound URLs in project configuration.
- Test expired, missing, and insufficient-scope credentials.
14. Optional line items and buyer-controlled changes#
A checkbox in an Island changes only browser state. It does not automatically change the quote's authoritative line items, total, acceptance snapshot, or PDF.
If buyers can select options, define the commercial workflow explicitly:
- Render selectable items with a stable server-side identifier and current price.
- Send the requested selection to a serverless function.
- Re-fetch and validate the quote, option eligibility, currency, and price.
- Apply supported CRM changes.
- Move the quote through the required draft/publish cycle.
- Wait for the new published state and updated totals.
- Refresh the buyer experience from authoritative quote data.
- Prevent acceptance while an update is pending.
Do not accept a browser-submitted amount as truth. Submit identifiers and choices; calculate or retrieve the actual commercial values on the server.
15. Quote lifecycle constraints that affect module design#
Published CPQ quotes behave like controlled snapshots. API behavior is not equivalent to freely editing a CMS page.
Observed current behavior documented and tested by Works by Design includes:
- a published quote is locked;
- patching an already associated line item may work while adding or removing line-item associations can return a successful HTTP response without changing the published quote;
- quote totals are calculated by HubSpot and should be treated as read-only;
- changing commercial contents may require moving the quote back to draft, waiting for unlock, applying changes, and publishing again;
- publishing is asynchronous and can need a short wait or retry before the quote URL/state is ready;
- acceptance captures the published quote snapshot, so UI-only option state is not contractual state.
These are important platform behaviors, but API details can change. Verify the lifecycle in a test account before building a workflow that mutates live quotes.
A safe republish sequence#
Published quote
-> move to DRAFT
-> wait until unlocked
-> update quote-specific line items/associations
-> verify calculated values
-> publish using the supported status
-> poll until published data and URL are ready
-> reload the buyer view
Use bounded retries with backoff. Record errors clearly and never leave the buyer believing an option was saved when the published quote did not change.
16. Creating CPQ quotes through the CRM API#
When a separate application creates the quote, it must construct the associations and lifecycle correctly. At a high level:
- create quote-specific line-item records rather than attaching mutable product-library assumptions directly;
- create the quote with the required properties and the CPQ template type expected by the account;
- associate the deal, contact/company, line items, and template using the correct association definitions;
- wait for quote readiness/unlock where required;
- publish in a separate step;
- poll for the generated public link instead of assuming immediate availability;
- treat totals and final acceptance status as HubSpot-managed state.
The Quotes CRM object is commonly identified as 0-14, but association types, required properties, scopes, template IDs, and publish statuses must be verified against the current HubSpot Quotes API documentation before implementation.
A custom module can display an API-created quote, but it cannot compensate for incorrect quote associations or an incomplete publish sequence.
17. A scalable quote-module architecture#
For a substantial quote, prefer small sections with a shared design system over one enormous component.
A useful module journey is:
- Header
- Cover
- Navigation
- Introduction
- Line items
- Pricing summary
- Mutual action plan
- Team
- Proof or case studies
- FAQ
- Resources
- Terms
- Signature or acceptance
This is the structure demonstrated by the Works by Design collection. It is not mandatory. Remove sections that do not help the buyer make or implement the decision.
Module boundaries#
A section deserves its own module when it has at least one of these characteristics:
- a distinct editor configuration;
- optional placement;
- its own quote data contract;
- independent interaction;
- substantially different PDF rules;
- a useful standalone purpose in another quote template.
Keep shared code limited to stable concerns such as tokens, currency formatting, context guards, and event names. Do not create a hidden dependency on module order unless the editor and fallback behavior make it explicit.
Document mode and card mode#
The collection supports both full-width document sections and contained cards. This is a useful general pattern: make layout mode an editor field, while keeping semantic content identical. PDF output should generally prefer the document form to avoid excessive borders, shadows, and fragmented pagination.
18. What the free module collection demonstrates#
The Works by Design Quote Module Collection is useful as reference code for:
- a complete multi-section quote journey;
- brand-aware cover and header sections;
- a countdown Island;
- sticky navigation that discovers available sections;
- custom line-item rendering and ordering;
- tiered-pricing display;
- editor-only line-item shortcuts;
- a products anchor module;
- pricing summary and commercial context;
- mutual action plans;
- team, proof, and resource repeaters;
- FAQs and terms with PDF-safe accordions;
- signature/acceptance presentation;
- debug and experimental modules;
- shared quote theme tokens;
- serverless test scaffolding.
Read it as an implementation library, not as a promise that every technique is a supported public API. In particular, internal editor event-bus actions should remain isolated and optional. Experimental/debug modules should not be shipped into a buyer-facing production template without review.
When to show code and when to link to the download#
Documentation should include short, complete snippets for the platform seams where developers commonly get stuck. That includes the four exports, ModuleFields, the hublData assignment, ?island imports, editor/preview/PDF branching, HubSpot-calculated line-item amounts, SDK guards, the serverless response envelope, and app-function metadata. Omitting those snippets forces a beginner to reverse-engineer the same conventions from a large codebase.
Do not paste entire 300–600 line production modules into the article. Use the download for full styling, all field declarations, icons, modals, and exhaustive rendering. Point to an exact file and explain what the reader should learn from it:
| Problem | Reference file inside the download |
|---|---|
| Small static quote module and data bridge | src/cms-assets/my-react-assets/components/modules/QuoteIntro/index.tsx |
| Interactive Island import and SSR wrapper | src/cms-assets/my-react-assets/components/modules/QuoteCover/index.tsx and its islands/QuoteCoverIsland.tsx |
| Custom line items, sorting, filtering, and authoritative amounts | src/cms-assets/my-react-assets/components/modules/QuoteLineItems/index.tsx |
| Tiered-pricing editor modal | src/cms-assets/my-react-assets/components/modules/QuoteLineItems/islands/TierModalIsland.tsx |
| Undocumented editor shortcuts, isolated behind guards | src/cms-assets/my-react-assets/components/modules/QuoteLineItems/islands/EditorButtonsIsland.tsx |
| PDF-safe accordion | src/cms-assets/my-react-assets/components/modules/QuoteTerms/index.tsx and its islands/QuoteTermsIsland.tsx |
| DOM-discovered sticky navigation | src/cms-assets/my-react-assets/components/modules/QuoteNav/index.tsx and its islands/QuoteNavIsland.tsx |
| Quote-context inspection | src/cms-assets/my-react-assets/components/modules/DebugQuoteContext/—development only |
| Serverless project wiring | src/app/functions/ and src/app/app-hsmeta.json |
This hybrid keeps the guide readable while still teaching the contracts a developer must understand to modify the downloaded code safely.
19. Platform limitations to design around#
The current quote editor is not identical to the CMS page editor. Current documented constraints include:
- only one drag-and-drop area;
- a HubSpot-controlled status bar that cannot be replaced by a custom module;
- custom modules cannot be inserted into an individual quote through the Quotes API; an API-created quote must use a template that already contains them;
- a custom Line items or Parties module is a presentation replacement only—the native module must remain, hidden, to preserve editing functionality;
- deploying changed module code affects future quotes and unpublished drafts, not quotes that were already published;
- removing a module from the project removes it from templates and unpublished quotes, while previously published quotes retain their published snapshot;
- a project with quote modules must currently start from the supplied project shape rather than normal CLI scaffolding;
- no online Design Manager code editing for React modules;
- fixed or opinionated document widths;
- limited control over certain native quote sections;
- quote data that is available only through the specific template context;
- asynchronous publication and PDF generation;
- browser interaction that cannot become contractual quote state by itself;
- undocumented editor internals that can change without notice.
Before promising a design, build one vertical slice containing real line items, an interactive element, acceptance configuration, and a generated PDF. That spike will expose the platform boundaries earlier than a static mock-up.
20. End-to-end development workflow#
Step 1: define the document#
List the sections, owners, editable content, required CRM data, interactive behavior, and PDF result. Mark contractual information separately from decorative content.
Step 2: inspect real quote context#
Create a restricted debug module, use a non-production quote, and record the exact paths and data types required. Remove broad dumps after discovery.
Step 3: build one static module#
Prove fields, data bridge, styling, editor availability, live rendering, and PDF rendering before creating the entire library.
Step 4: extract shared foundations#
Create theme tokens, formatters, null guards, and stable prop types. Avoid premature abstraction across modules that have not yet proven similar.
Step 5: implement the commercial section#
Build the line-item and totals experience using real pricing cases. Test discounts, currencies, recurring items, and tiers.
Step 6: add Islands#
Add interaction only after the static output is correct. Verify server rendering and hydration after every interactive addition.
Step 7: add serverless workflows#
Introduce privileged APIs behind validated serverless endpoints. Treat buyer-controlled commercial changes as a lifecycle workflow, not a UI toggle.
Step 8: test every runtime#
Run the full matrix below before release.
Step 9: deploy and install#
Upload from the folder containing hsproject.json:
hs project upload
After the build/deploy succeeds, open Commerce > Quotes, create a quote using the intended template, open the module sidebar with the plus icon, and drag the custom module into the quote body. Test both a template/blueprint and an individual quote. If the project includes a private app/serverless function, complete its account installation as well. Follow HubSpot's current Create quote modules guide if the UI or CLI flow has changed.
Step 10: monitor platform-sensitive adapters#
Maintain a short regression checklist for editor shortcuts, quote SDK actions, lifecycle mutations, and PDF behavior. These are the areas most likely to be affected by platform changes.
21. Release test matrix#
Editor and configuration#
- Module appears in the blueprint/template editor.
- Every field has a clear label, default, and help text where needed.
- Repeater add, remove, reorder, and empty states work.
- Editor-only actions do not appear to buyers.
- Missing quote associations show useful guidance rather than an exception.
- Module reordering does not break navigation or heading order.
Live quote#
- Desktop, tablet, and mobile layouts work.
- Keyboard navigation and focus states work.
- Screen-reader names and expanded states are accurate.
- Hydration produces no browser-console errors.
- Slow and failed network requests show recoverable states.
- Currency, locale, quantities, discounts, and recurring periods display correctly.
- Acceptance/payment controls match the quote's enabled capabilities.
Commercial accuracy#
- One-time and recurring line items are tested.
- Percentage and fixed discounts are tested.
- Tier boundaries are tested.
- Quote totals match HubSpot's native values.
- Optional item changes persist to authoritative quote state before acceptance.
- Refreshing the page does not lose a saved choice.
- Concurrent or repeated submissions are handled safely.
PDF#
- Generate an actual PDF, not only browser print preview.
- Every accordion and essential panel is visible.
- Tables wrap and paginate acceptably.
- Interactive-only controls are removed or replaced.
- Links have meaningful labels.
- Terms, totals, and signature content are present.
- Brand colours remain legible in print.
Security and privacy#
- No token or secret appears in source, rendered HTML, props, logs, or network responses.
- Serverless input is validated and authorised.
- Debug context modules are removed or restricted.
- Error messages do not expose internal payloads.
- Outbound hosts and API scopes are minimal.
- Personal and commercial data passed to Islands is limited to what the browser needs.
22. Troubleshooting#
| Symptom | Likely cause | What to check |
|---|---|---|
| Module does not appear in the quote editor | Wrong availability metadata or deployment location | Confirm QUOTE and QUOTE_BLUEPRINT, project structure, upload, and app installation |
| Component renders but quote data is empty | Incorrect context path or missing association | Inspect a real test quote, add null guards, and verify the HubL JSON output |
| HubL data causes a parse/render error | Invalid JSON serialisation | Use JSON-safe filters and avoid manual quoting |
| Boolean setting behaves backwards | String versus boolean value | Normalise at the component boundary |
| Island fails during build or SSR | Browser API accessed server-side | Move browser work into an Island effect and keep props serialisable |
| Interactive content disappears from PDF | Content created or expanded only in browser state | Render bodies statically, detect PDF state, and add print CSS |
| Line-item ordering is wrong | Positions compared as strings | Convert position values to numbers before sorting |
| Displayed total differs from HubSpot | Recalculated amount ignored discounts/tiers | Render HubSpot's provided amount and verify pricing metadata separately |
| Serverless call returns 403 or cannot reach host | App permissions, outbound URL, scope, or install issue | Check app metadata, scopes, permitted URLs, deployment, and installation |
| Quote update returns success but published quote does not change | Published quote is locked or association mutation was ignored | Move through the tested draft/unlock/update/republish sequence and verify state |
| Direct editor shortcut stops working | Internal event-bus contract changed | Feature-detect, fall back gracefully, and update the isolated adapter |
| Brand values are missing | Assumed normal website context | Inspect brand_settings, quote CSS variables, and configured field fallbacks |
23. Supported, observed, and application-specific behavior#
Good developer documentation should distinguish three categories:
Supported public platform#
These patterns should be anchored in current HubSpot documentation:
- coded quote module exports and metadata;
- CMS React fields;
- HubL data templates;
- React Islands;
- quote development SDK actions;
- HubSpot serverless functions and project configuration;
- CRM Quotes API operations;
- documented PDF/print behavior.
Observed platform behavior#
These patterns require a test account and regression coverage:
- the exact runtime shape of some quote-context values;
- asynchronous publish timing;
- locking and line-item association behavior on published quotes;
- internal editor event-bus shortcuts;
- quote-renderer CSS variables that are not part of an explicit public contract.
Application conventions#
These are choices made by your implementation:
- SKU prefixes;
- section IDs and custom browser events;
- document/card layout modes;
- fallback colours and spacing tokens;
- the order of modules;
- whether optional items trigger republishing.
Labeling these categories prevents developers from mistaking an effective implementation convention for a guaranteed platform API.
24. Where to go next#
- Read HubSpot's current Create quote modules guide for the supported starter and deployment workflow.
- Download the Works by Design Quote Module Collection.
- Deploy one simple module to a test account.
- Inspect a real quote's context and replace sample data with a minimal contract.
- Generate a PDF before adding complex interaction.
- Build commercial mutations behind serverless validation and a tested quote lifecycle.
The shortest path to a complex quote is not one giant component. It is a small, correct static module; a deliberate data bridge; a PDF-safe document; and then carefully layered interaction.