Analytics Module Linking - Read-Only Approach ✅
The Perfect Solution: Read-Only Module Link
We're using a read-only module link between Analytics and Website. This gives us the best of both worlds:
✅ Simple: Uses existing website_id field (no join table)
✅ Powerful: Enables query.graph() for complex queries
✅ Fast: No additional joins or overhead
✅ Clean: Leverages MedusaJS's built-in linking system
Implementation
// src/links/website-analytics-link.ts
import { defineLink } from "@medusajs/framework/utils";
import WebsiteModule from "../modules/website";
import AnalyticsModule from "../modules/analytics";
export default defineLink(
{
linkable: AnalyticsModule.linkable.analyticsEvent,
field: "website_id", // ← Use existing field!
},
WebsiteModule.linkable.website,
{
readOnly: true, // ← No join table created
}
);
What this does:
- ✅ Uses the existing
website_idfield inAnalyticsEvent - ✅ No join table created (read-only)
- ✅ Enables graph queries to fetch related data
- ✅ Zero overhead - just uses the field that's already there
Usage Examples
1. Get Analytics Event with Website Details
const query = container.resolve(ContainerRegistrationKeys.QUERY);
const { data } = await query.graph({
entity: "analytics_event",
fields: [
"*",
"website.*", // ← Automatically resolves via website_id
],
filters: { id: "event_123" }
});
// Result:
{
id: "event_123",
website_id: "website_abc123",
event_type: "pageview",
pathname: "/products",
website: { // ← Website details included!
id: "website_abc123",
domain: "example.com",
name: "My Website",
status: "Active"
}
}
2. Get Website with All Analytics Events
const { data } = await query.graph({
entity: "website",
fields: [
"*",
"analytics_events.*", // ← All events for this website
],
filters: { id: "website_abc123" }
});
// Result:
{
id: "website_abc123",
domain: "example.com",
name: "My Website",
analytics_events: [ // ← All analytics events
{ id: "event_1", event_type: "pageview", pathname: "/" },
{ id: "event_2", event_type: "pageview", pathname: "/products" },
{ id: "event_3", event_type: "custom_event", event_name: "signup" }
]
}
3. Get Recent Events with Website Context
const { data } = await query.graph({
entity: "analytics_event",
fields: [
"id",
"event_type",
"pathname",
"timestamp",
"website.domain", // ← Just the fields you need
"website.name",
],
filters: {
timestamp: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
},
pagination: { take: 100 }
});
// Result: Recent events with minimal website data
[
{
id: "event_1",
event_type: "pageview",
pathname: "/",
timestamp: "2024-01-15T10:30:00Z",
website: {
domain: "example.com",
name: "My Website"
}
},
// ...
]
4. Multi-Website Analytics Dashboard
// Get all websites with event counts
const { data: websites } = await query.graph({
entity: "website",
fields: [
"*",
"analytics_events.id", // ← Just IDs for counting
]
});
// Process results
const websiteStats = websites.map(website => ({
id: website.id,
domain: website.domain,
name: website.name,
total_events: website.analytics_events?.length || 0
}));
// Result:
[
{ id: "web_1", domain: "site1.com", name: "Site 1", total_events: 1234 },
{ id: "web_2", domain: "site2.com", name: "Site 2", total_events: 567 },
]
Comparison: Before vs After
Before (Simple Reference Only):
// Need 2 separate queries
const events = await analyticsService.listAndCountAnalyticsEvents({
website_id: "website_abc123"
});
const website = await websiteService.retrieveWebsite("website_abc123");
// Manually combine
const result = {
website: website,
events: events[0],
count: events[1]
};
After (Read-Only Link):
// Single query with graph
const { data } = await query.graph({
entity: "website",
fields: ["*", "analytics_events.*"],
filters: { id: "website_abc123" }
});
// Already combined!
const result = data[0]; // Has website + events
Benefits
✅ Performance
- No join table: Uses existing
website_idfield - No extra queries: Single graph query gets everything
- Indexed:
website_idis already indexed for fast lookups
✅ Simplicity
- Existing field: No schema changes needed
- Read-only: Can't accidentally modify relationships
- Type-safe: MedusaJS handles typing automatically
✅ Flexibility
- Graph queries: Use powerful
query.graph()API - Selective fields: Only fetch what you need
- Nested data: Get related data in one call
✅ Backwards Compatible
- Still works: Existing queries with
website_idstill work - Optional: Can use graph queries OR simple queries
- No breaking changes: All existing code continues to work
When to Use Each Approach
Use Simple Query (Direct Service):
// ✅ Best for: Simple filtering by website_id
const [events, count] = await analyticsService.listAndCountAnalyticsEvents({
website_id: "website_abc123",
event_type: "pageview"
});
Use when:
- You only need analytics data (no website details)
- Simple filters (website_id, event_type, pathname)
- Pagination and counting
- Maximum performance
Use Graph Query (With Link):
// ✅ Best for: Need website details with analytics
const { data } = await query.graph({
entity: "analytics_event",
fields: ["*", "website.domain", "website.name"],
filters: { id: "event_123" }
});
Use when:
- Need website details with analytics data
- Building dashboards or reports
- Displaying event with context
- Multi-website overviews
Real-World Examples
Example 1: Event Details Page
// Show event with website context
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { id } = req.params;
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data } = await query.graph({
entity: "analytics_event",
fields: [
"*",
"website.id",
"website.domain",
"website.name"
],
filters: { id }
});
res.json({ event: data[0] });
};
Example 2: Website Analytics Dashboard
// Show website with recent analytics
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { id } = req.params;
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data } = await query.graph({
entity: "website",
fields: [
"*",
"analytics_events.*"
],
filters: { id }
});
const website = data[0];
const recentEvents = website.analytics_events
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 100);
res.json({
website: {
id: website.id,
domain: website.domain,
name: website.name
},
recent_events: recentEvents,
total_events: website.analytics_events.length
});
};
Example 3: Multi-Website Overview
// Admin dashboard: All websites with stats
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: websites } = await query.graph({
entity: "website",
fields: [
"id",
"domain",
"name",
"status",
"analytics_events.id",
"analytics_events.timestamp"
]
});
const stats = websites.map(website => {
const events = website.analytics_events || [];
const last24h = events.filter(e =>
new Date(e.timestamp) > new Date(Date.now() - 24 * 60 * 60 * 1000)
);
return {
id: website.id,
domain: website.domain,
name: website.name,
status: website.status,
total_events: events.length,
events_24h: last24h.length
};
});
res.json({ websites: stats });
};
Architecture Diagram
┌─────────────────┐
│ Website │
│ id: "web_123" │
│ domain: "..." │
└────────┬────────┘
│
│ Read-only link
│ (uses website_id field)
│
▼
┌──────────────────────┐
│ AnalyticsEvent │
│ website_id: "web_123" │ ← Existing field!
│ event_type: "..." │
│ pathname: "..." │
└──────────────────────┘
No join table created!
Just uses existing website_id field.
Summary
The read-only module link is the perfect solution because:
- ✅ Zero overhead: Uses existing
website_idfield - ✅ No join table: Read-only means no extra tables
- ✅ Graph queries: Enables powerful
query.graph()API - ✅ Backwards compatible: Existing queries still work
- ✅ Type-safe: MedusaJS handles types automatically
- ✅ Flexible: Use simple OR graph queries as needed
Best of both worlds! 🎉
You get the simplicity of a direct field reference AND the power of graph queries, with zero additional overhead.