Building a Real CRM Pipeline on Top of Shopify
Shopify knows about customers. It does not know about leads. Here is how to build the missing 90% of the customer journey with HubSpot, Next.js API routes, and some honest engineering.
The Gap in Shopify
Shopify is a checkout system. A very good one. It handles product management, inventory, payments, tax calculation, shipping, and order fulfillment. For those things, it is best in class.
But Shopify has no concept of a lead. There is no pipeline. No lifecycle stages. No lead scoring. No follow-up sequences. A visitor either becomes a customer (by purchasing) or they do not exist in Shopify's world.
For commodity products, this is fine. A $25 purchase does not need a pipeline. The buyer sees it, wants it, buys it.
For high-ticket products ($500+), this is a catastrophic blind spot. The buyer visits 3-7 times before purchasing. They research. They compare. They hesitate. During those 3-6 weeks of deliberation, Shopify gives you zero tools to engage them, nurture them, or even know they exist.
The Four Capture Points
A proper CRM pipeline for e-commerce needs to capture leads at four distinct touchpoints. Each one catches a different buyer at a different stage of commitment.
Capture Point 1: Value-Exchange Forms
The simplest and most effective lead capture: offer something valuable in exchange for an email address. Not a "Subscribe to our newsletter" form. Nobody wants a newsletter from a product company. They want information that helps them make a decision.
Examples that actually convert:
- Product comparison guide (PDF): "Compare the X500 vs X700 vs X900"
- Buyer's checklist: "8 things to verify before buying [product category]"
- Spec sheet download: detailed technical specifications
- Configuration worksheet: "Plan your custom order"
These convert at 15-25% because they provide real value. The buyer was going to research this anyway. You are saving them time.
Here is the API route that handles the form submission:
// app/api/lead-capture/route.js
import { NextResponse } from 'next/server';
const HUBSPOT_TOKEN = process.env.HUBSPOT_ACCESS_TOKEN;
export async function POST(request) {
const body = await request.json();
const { email, firstName, source, productInterest } = body;
// Validate
if (!email || !email.includes('@')) {
return NextResponse.json(
{ error: 'Valid email required' },
{ status: 400 }
);
}
// Create or update contact in HubSpot
const hubspotRes = await fetch(
'https://api.hubapi.com/crm/v3/objects/contacts',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${HUBSPOT_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
properties: {
email,
firstname: firstName || '',
lead_source: source,
product_interest: productInterest,
lifecyclestage: 'lead',
hs_lead_status: 'NEW'
}
})
}
);
// HubSpot returns 409 if contact exists. Update instead.
if (hubspotRes.status === 409) {
const existing = await hubspotRes.json();
const contactId = existing.message.match(/ID: (\d+)/)?.[1];
if (contactId) {
await fetch(
`https://api.hubapi.com/crm/v3/objects/contacts/${contactId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${HUBSPOT_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
properties: {
product_interest: productInterest,
last_form_submission: source
}
})
}
);
}
}
return NextResponse.json({ success: true });
}
The key detail: the 409 handler. When a returning visitor submits a second form, we update their existing contact instead of failing. This means every interaction enriches the lead profile instead of creating duplicates.
Capture Point 2: Exit-Intent Popups (Done Right)
I know. Popups. Everyone hates them. But the data is clear: exit-intent popups on product pages convert at 4-8% when the offer is relevant and the timing is right.
The rules for non-annoying popups:
- Only trigger on exit intent (cursor moves toward browser chrome on desktop, scroll-up on mobile)
- Only on product pages, never on the homepage or blog
- Only once per session. If they dismiss it, it does not come back
- The offer must be specific to the product they were viewing
- No countdown timers. No fake urgency. No "Wait! Before you go!"
Implementation with a React hook:
// hooks/useExitIntent.js
import { useState, useEffect, useCallback } from 'react';
export function useExitIntent(productHandle) {
const [showPopup, setShowPopup] = useState(false);
const handleMouseLeave = useCallback((e) => {
// Only trigger when cursor moves to top of viewport
if (e.clientY > 50) return;
// Check if already shown this session
const key = `exit_shown_${productHandle}`;
if (sessionStorage.getItem(key)) return;
sessionStorage.setItem(key, 'true');
setShowPopup(true);
}, [productHandle]);
useEffect(() => {
document.addEventListener('mouseleave', handleMouseLeave);
return () => {
document.removeEventListener('mouseleave', handleMouseLeave);
};
}, [handleMouseLeave]);
return { showPopup, dismiss: () => setShowPopup(false) };
}
The popup content: "Want the full spec sheet for the [Product Name]? We'll email it to you." One field: email. One button: "Send Specs." That is it. It is helpful, not desperate.
Capture Point 3: Pre-Checkout Email
This is the most valuable capture point and the one most stores miss entirely. When a buyer clicks "Add to Cart" but has not checked out, they have expressed high purchase intent. They are 10x more likely to convert than a casual browser.
Before redirecting to Shopify checkout, show a single-field email capture:
// components/CartDrawer.jsx
function CartDrawer({ items, checkoutUrl }) {
const [email, setEmail] = useState('');
const [captured, setCaptured] = useState(false);
async function handleCheckout() {
// Capture email before redirecting to Shopify
if (email && !captured) {
await fetch('/api/lead-capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
source: 'pre_checkout',
productInterest: items.map(i => i.handle).join(', ')
})
});
setCaptured(true);
}
// Redirect to Shopify checkout
window.location.href = checkoutUrl;
}
return (
<div className="cart-drawer">
{/* Cart items rendering */}
{items.map(item => (
<CartItem key={item.variantId} item={item} />
))}
<div className="cart-email">
<label htmlFor="checkout-email">
Email for order updates
</label>
<input
id="checkout-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="your@email.com"
/>
</div>
<button onClick={handleCheckout}>
Continue to Checkout
</button>
</div>
);
}
The positioning matters: "Email for order updates." Not "Sign up for our mailing list." The buyer thinks they are entering their email for the order. And they are. But they are also entering HubSpot's pipeline as a high-intent lead. If they abandon the Shopify checkout, you have their email and know exactly which products they wanted.
Capture Point 4: AI Chat Assistant
This one is newer and more powerful than the others. An AI assistant on your product pages that can answer questions about the catalog, compare products, and naturally capture email addresses during the conversation.
I wrote a full post about this (AI Commerce: What Happens When Your Store Can Talk Back), but the CRM integration works like this: when the AI resolves a question that indicates purchase intent ("What's the lead time on the X900?"), it offers to email the answer along with a detailed quote. The email capture happens as a natural part of the conversation, not as a popup or a form.
Capture rates from AI chat: 20-35%. The highest of any capture point. Because the buyer asked for it.
The HubSpot Pipeline
Now that leads are flowing in from four touchpoints, you need a pipeline to manage them. Here is the lifecycle we use:
LIFECYCLE STAGES:
1. SUBSCRIBER
Trigger: Newsletter signup (low intent)
Action: Add to weekly email digest
HubSpot: lifecyclestage = subscriber
2. LEAD
Trigger: Form submission, spec download, popup capture
Action: 3-email nurture sequence
HubSpot: lifecyclestage = lead, hs_lead_status = NEW
3. MARKETING QUALIFIED (MQL)
Trigger: 3+ page views + 1 form submission
OR pre-checkout email capture
OR AI chat email capture
Action: Move to sales sequence
HubSpot: lifecyclestage = marketingqualifiedlead
4. SALES QUALIFIED (SQL)
Trigger: Manual (sales team reviews MQL)
OR "Request a Quote" form
Action: Direct sales outreach
HubSpot: lifecyclestage = salesqualifiedlead
5. OPPORTUNITY
Trigger: Sales confirms buying intent
Action: Custom quote, follow-up cadence
HubSpot: lifecyclestage = opportunity
6. CUSTOMER
Trigger: Shopify order webhook
Action: Post-purchase sequence
HubSpot: lifecyclestage = customer
Lead Scoring
HubSpot has built-in lead scoring, but for e-commerce you need custom scoring rules. Here is what we configure:
SCORING MODEL:
Page views:
+1 per product page view
+3 for specification page view
+5 for pricing/quote page view
Form interactions:
+5 spec sheet download
+10 "request a quote" submission
+15 pre-checkout email capture
+20 AI chat email capture (with purchase intent)
Email engagement:
+2 email open
+5 email click
+10 reply to sales email
Decay:
-2 per week of inactivity
Thresholds:
15+ points = MQL (auto-promote)
30+ points = prioritized for sales review
50+ points = hot lead alert to sales team
The scoring triggers automated workflows. When a lead crosses 15 points, they automatically move from LEAD to MQL, and HubSpot enrolls them in the sales sequence. When they cross 50 points, the sales team gets a Slack notification with the lead's full activity history.
Tracking Anonymous Visitors
The hardest part of the pipeline is the anonymous phase. Before a visitor submits a form, you do not know who they are. But you can still track their behavior and connect it retroactively once they identify themselves.
The approach:
// lib/tracking.js
// Generate or retrieve anonymous visitor ID
function getVisitorId() {
let id = localStorage.getItem('cdm_visitor_id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('cdm_visitor_id', id);
}
return id;
}
// Track page view
async function trackPageView(page, productHandle = null) {
const visitorId = getVisitorId();
await fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
visitorId,
event: 'page_view',
page,
productHandle,
timestamp: new Date().toISOString()
})
});
}
// When visitor identifies (form submit), merge history
async function identifyVisitor(email) {
const visitorId = getVisitorId();
await fetch('/api/identify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
visitorId,
email
})
});
}
On the server side, the /api/identify endpoint takes the anonymous visitor ID, looks up all their tracked events, and associates them with the HubSpot contact. The sales team sees: "This lead viewed the X900 product page 4 times over 2 weeks, downloaded the spec sheet, then submitted a quote request."
That context changes the sales conversation entirely. Instead of "Hi, you requested a quote, which product were you interested in?" it becomes "Hi, I see you've been looking at the X900. I noticed you downloaded the spec sheet. Do you have questions about the installation requirements?"
The Shopify Order Webhook
When a lead finally purchases through Shopify checkout, you need to close the loop. A Shopify webhook fires on order creation and updates the HubSpot contact to CUSTOMER status:
// app/api/webhooks/shopify-order/route.js
import { NextResponse } from 'next/server';
import crypto from 'crypto';
const SHOPIFY_WEBHOOK_SECRET = process.env.SHOPIFY_WEBHOOK_SECRET;
const HUBSPOT_TOKEN = process.env.HUBSPOT_ACCESS_TOKEN;
export async function POST(request) {
// Verify Shopify webhook signature
const body = await request.text();
const hmac = request.headers.get('x-shopify-hmac-sha256');
const hash = crypto
.createHmac('sha256', SHOPIFY_WEBHOOK_SECRET)
.update(body, 'utf8')
.digest('base64');
if (hash !== hmac) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
const order = JSON.parse(body);
const email = order.email;
if (!email) {
return NextResponse.json({ ok: true });
}
// Search for existing contact
const searchRes = await fetch(
'https://api.hubapi.com/crm/v3/objects/contacts/search',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${HUBSPOT_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filterGroups: [{
filters: [{
propertyName: 'email',
operator: 'EQ',
value: email
}]
}]
})
}
);
const searchData = await searchRes.json();
if (searchData.total > 0) {
const contactId = searchData.results[0].id;
// Update to customer
await fetch(
`https://api.hubapi.com/crm/v3/objects/contacts/${contactId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${HUBSPOT_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
properties: {
lifecyclestage: 'customer',
shopify_order_id: order.id.toString(),
shopify_order_total: order.total_price,
shopify_order_date: order.created_at
}
})
}
);
}
return NextResponse.json({ ok: true });
}
The Full Architecture
┌──────────────────────────────────────────────┐
│ Next.js Frontend (Vercel) │
│ │
│ Capture Point 1: Value-exchange forms │
│ Capture Point 2: Exit-intent popup │
│ Capture Point 3: Pre-checkout email │
│ Capture Point 4: AI chat assistant │
│ Anonymous tracking: localStorage + API │
│ │
│ /api/lead-capture → HubSpot │
│ /api/track → Event store │
│ /api/identify → Merge anonymous │
│ /api/webhooks/order → Close the loop │
└───────┬──────────┬──────────┬────────────────┘
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Shopify │ │HubSpot │ │ Vercel │
│Products│ │ CRM │ │ KV │
│Checkout│ │Pipeline│ │ Events │
│Orders │ │Scoring │ │Tracking│
└────────┘ └────────┘ └────────┘
Shopify stays in its lane: products, checkout, orders. HubSpot handles the pipeline: leads, scoring, sequences, workflows. Next.js API routes are the glue between them. Vercel KV stores anonymous tracking events until the visitor identifies themselves.
Results
Here are the numbers from a client store that implemented this full pipeline. Selling high-end outdoor equipment, average order value $2,800.
BEFORE (Shopify template, no CRM):
Monthly visitors: 11,200
Known leads: 0 (no capture mechanism)
Conversion rate: 0.5%
Monthly revenue: $156,800
AFTER (Headless + HubSpot pipeline):
Monthly visitors: 11,200 (same traffic)
Leads captured: 1,680/month (15% capture rate)
MQLs generated: 340/month
SQLs generated: 85/month
Conversion rate: 1.3%
Monthly revenue: $407,680
Pipeline value visible in CRM: $238,000/month
(leads in nurture who have not yet purchased)
The conversion rate increase alone added $250K/month. But the real value is the pipeline visibility. Before, 99.5% of visitors were invisible. Now, 15% enter the CRM. The sales team has a queue of qualified leads to work instead of waiting for orders to appear in Shopify.
The Trade-offs
This is not free. The honest costs:
- HubSpot: $800-1,600/month for Marketing Hub Professional (you need workflows and lead scoring)
- Development: $15,000-30,000 for the initial build (headless frontend + API routes + CRM integration)
- Maintenance: 4-8 hours/month to manage sequences, review scoring, and update content
- Complexity: More moving parts. Shopify + HubSpot + Vercel + API routes. When something breaks, debugging spans multiple systems
For a store doing $50K/month in revenue with a $200 AOV, this is overkill. The math does not justify the investment.
For a store doing $150K+/month with a $1,000+ AOV, this is the single highest-ROI investment you can make. The pipeline pays for itself in the first month.
If your store has traffic but no pipeline, you are watching money walk out the door. The visitors are there. You just cannot see them. Let's fix that.