Aumiqx
AUM

We Replaced TalkJS with a Self-Hosted Medusa Plugin in 7 Days. Then We Open-Sourced It.

A per-MAU chat tool on a marketplace is a tax on growth. We ripped TalkJS out of a live Medusa + MercurJS marketplace, built real-time buyer↔seller messaging on Postgres + SSE in a week, and published it as @aumiqx/medusa-plugin-messages under MIT. Here's the build log, the bugs, and how to install it.

Open Source|Axit @ Aumiqx||16 min read
medusajsmercurjsopen source

The Per-MAU Tax Nobody Mentions in a Marketplace Pitch

Here's a problem that doesn't show up in any Y Combinator fundraising deck, but kills marketplace unit economics the moment you try to scale: per-monthly-active-user chat pricing.

You go to the TalkJS pricing page (or Intercom, or any of the "drop-in chat" SaaS) and the plans start reasonable. $50, maybe $100. But every plan has a MAU cap. 100 MAU, 1,000 MAU, 10,000 MAU.

In a marketplace, every buyer is a potential chat user. Every person who ever messaged a seller about "is this available in medium" is a monthly active user. Multiply your buyer base by your chat rate, and the invoice gets ugly fast.

We were building Yoginii — a women-focused multi-vendor marketplace running on Medusa v2 + MercurJS. Four seeded vendors, growing. The client wanted buyer↔seller chat. The TalkJS integration was in the MercurJS template. It worked. It cost a trajectory.

So one evening we opened the console in Claude Code and typed:

"Ok bro, we need to replace TalkJS. Free messaging. Let's analyze first."

Seven days later, we shipped a fully custom messaging module across three apps (admin, vendor portal, storefront), ripped out TalkJS completely, published the plugin to npm under MIT, and hooked an offline-email fallback through the existing Resend integration.

This is what actually happened.

What Yoginii Actually Needed

Every open-source writeup starts with "we built X in a weekend." Most skip the context that makes the decisions legible.

Yoginii's shape:

  • Stack: Medusa.js v2 backend + Next.js 16 storefront + vendor portal (Vite + React) + admin (MercurJS fork of Medusa admin)
  • Auth model: three distinct actor types — customer (Bearer/cookie), seller (Bearer in localStorage), admin (cookie session)
  • Hosting: Hostinger VPS, single Postgres, PM2 fork mode (one Node process)
  • Existing TalkJS integration: surface spread across all three apps — badge counts, inbox pages, chat widgets on order detail

The must-haves:

  • Customer ↔ vendor threads, scoped per order (not per-vendor, because "order #42" is how both parties remember the conversation)
  • Vendor ↔ admin support threads
  • Real-time feel (≤1s latency when possible)
  • Unread badges everywhere
  • Email fallback when the recipient is offline (otherwise messages die)
  • No external SaaS, no per-MAU bill

The nice-to-haves we explicitly deferred:

  • Typing indicators (WebSocket territory — overkill for v1)
  • Attachments (blocked on S3/R2 which wasn't configured yet)
  • Full-text search (pg_trgm polish, not day-one)

Knowing what to leave out is half the battle. The other half is picking a boring, durable architecture.

Three Tables, Three Workflows, Zero External SaaS

Before writing a line of code, we asked Claude to propose the shape. Not "build a chat module" — specifically: "model three actor types, per-order scoping, per-participant unread tracking, real-time delivery without WebSockets."

What came back was refreshingly boring:

conversation
  id, type (order_customer_vendor | vendor_admin),
  order_id?, seller_id?, customer_id?,
  subject?, last_message_at, metadata

conversation_participant
  id, actor_type (customer | seller | admin), actor_id,
  last_read_at, last_seen_at

message
  id, author_actor_type, author_actor_id, body

Three tables. Unique index on (conversation_id, actor_type, actor_id) so participants don't duplicate. Partial indexes on deleted_at IS NULL for soft-delete-friendly query plans. One composite index on (conversation_id, created_at DESC) for the thread-view query.

The clever bit — and it's not clever, it's just honest — is that unread counts are derived, not stored. A message is "unread for viewer X" if it was created after X's participant.last_read_at and X didn't author it. No separate unread table, no denormalized counters, no cache invalidation hell. Postgres counts it each time you ask.

Three workflows do all the work

  • createOrGetConversationWorkflow — idempotent, keyed by (type, order_id, seller_id, customer_id). Clicking "Contact seller" twice doesn't duplicate.
  • sendMessageWorkflow — trims the body, persists the message, bumps conversation.last_message_at, emits messages.message.created via emitEventStep so subscribers only fire on workflow success.
  • markReadWorkflow — updates participant.last_read_at + last_seen_at to now(), emits messages.conversation.read.

The whole module is under 1,000 lines of TypeScript. No fancy patterns. No plugin architecture. Just Medusa's service factory, three models, three workflows, and eighteen API route files (six per auth scope × three scopes).

Verifying Every Guess Against the Medusa Docs MCP

Here's a habit that saved us probably three days of debugging: we never let Claude guess at Medusa v2 API shapes.

Medusa v2 is new enough that LLMs trained before late 2025 routinely invent methods that don't exist. service.updateX({ selector, data })? sdk.store.customer.create(...)? emitEventStep vs resolving Modules.EVENT_BUS? The signatures are close enough to v1 that confident-sounding hallucinations compile.

The Medusa team ships a documentation MCP server. Before every workflow or route file, we asked the MCP:

  • "What's the exact signature of the auto-generated update method on a service extending MedusaService?"
  • "How do I emit an event that subscribers can listen to — inside a step or via a helper?"
  • "Is requestOrderTransferWorkflow the right way to transfer a guest order to a registered customer?"

The third question is where verification paid off big. The naive answer (and LLMs will give it to you) is "yes, use requestOrderTransferWorkflow." But the docs revealed it's designed for customer-to-customer transfers with an email-verified two-step accept. For our guest-promotion flow (where the claim token is already the verification), it would fire a redundant confirmation email and create an order-change that had to be explicitly accepted. Wrong tool.

The correct approach: update order.customer_id directly inside our own workflow, because we already verified identity via the single-use SHA-256-hashed claim token. The docs told us this was safe. The LLM's first instinct would have sent us down a two-week detour.

"Let me verify my patterns against the live Medusa v2 docs before I keep going." Every time we said that, we avoided a bug.

The Seven-Day Build Log (Actually Seven Days)

Running tally from the commit history on the Yoginii staging branch:

DayScopeSurfaces
1Module scaffold, models, migration, serviceBackend only. Compiles, migrates cleanly on staging.
2Workflows + 12 API routes (admin, vendor, store)Backend end-to-end. Smoke-testable via curl.
3Admin inbox UI replaces TalkJSManish can actually use it.
4Vendor portal inbox + storefront "Contact seller" + customer inboxThree frontends live. Full loop possible.
5aSSE real-time push across all three apps<1s latency for admin. Polling keeps vendors and customers near-real-time.
5bResend email fallback + vendor fetch-based SSEOffline users get an email. Vendor SSE works despite Bearer-in-localStorage.
6TalkJS teardown — uninstall packages, delete dormant components, strip env definesZero external chat dep. Repo 2,200 lines lighter.
7Polish: admin sidebar unread badge, admin auto-joins threads, storefront SSE proxy routeFeature complete for v1.

Each day ended with a push to staging and a successful deploy. The main branch got 5+ commits ahead of origin — we only pushed what the auto-deploy needed (after burning a few GitHub Actions minutes double-pushing everything, which the client quickly called out. Fair.).

Real-Time Without WebSockets (And Why EventSource Lies to You)

We picked Server-Sent Events (SSE) over WebSockets for one reason: SSE runs over plain HTTP, keeps working behind nginx with zero config, and doesn't need sticky sessions when we eventually scale horizontally. Fastify has first-class support. Browser EventSource handles reconnection for you.

Architecture:

  • Single-process Node EventEmitter in the plugin, keyed by conversation_id so subscribers only get their own events. Listener cap bumped to 1024 because SSE fan-out blows past the default 10 quickly.
  • Subscriber bridges messages.message.created (workflow event) → in-process bus → open SSE connections.
  • SSE endpoint sets Content-Type: text/event-stream, X-Accel-Buffering: no (nginx), writes an initial :connected ack so EventSource.onopen fires immediately, and sends keep-alive : comments every 30s.
  • Clean shutdown on res.close / res.error.

This works perfectly in admin because admin auth is cookie-based. new EventSource(url, { withCredentials: true }) and cookies fly along.

Then the vendor portal punched us

Vendor portal auth is Bearer-in-localStorage. EventSource can't set custom headers. So the first version silently 401'd. Polling kept the UI alive, but real-time was dead.

We wrote a thin fetch-based SSE client:

const res = await fetch(url, {
  method: "GET",
  headers: { Authorization: `Bearer ${bearer}`, Accept: "text/event-stream" },
  signal: controller.signal,
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ""
let currentEvent = "message"
while (true) {
  const { value, done } = await reader.read()
  if (done) break
  buffer += decoder.decode(value, { stream: true })
  let idx: number
  while ((idx = buffer.indexOf("\n\n")) !== -1) {
    const frame = buffer.slice(0, idx)
    buffer = buffer.slice(idx + 2)
    for (const line of frame.split(/\r?\n/)) {
      if (line.startsWith("event:")) currentEvent = line.slice(6).trim()
      else if (line.startsWith("data:") && currentEvent === "message.created") {
        onMessage()
      }
    }
    currentEvent = "message"
  }
}

Twenty lines. Handles SSE framing manually. Aborts on unmount. Bearer flies. Vendor real-time works.

The storefront got a third solution

Storefront customers authenticate via an HTTP-only cookie set by a Next.js server action. Browser JS can't read HTTP-only cookies, so neither the fetch-SSE pattern nor raw EventSource works reliably across origins.

Answer: a same-origin Next.js route handler that reads the cookie server-side and pipes the upstream response body straight through.

// app/api/conversation-stream/[id]/route.ts
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export async function GET(req, { params }) {
  const { id } = await params
  const auth = await getAuthHeaders()
  const upstream = await fetch(
    `${BACKEND}/store/conversations/${id}/stream`,
    { headers: { Authorization: auth.authorization, ... }, signal: req.signal }
  )
  return new Response(upstream.body, {
    headers: { "Content-Type": "text/event-stream", ... }
  })
}

Three auth models, three SSE clients. Polling stays on in all three as the safety net.

The Day The Build Broke Because We Didn't Create an Empty Folder

First real push to staging. Turbo runs medusa plugin:build across every workspace plugin. Five tasks complete. Then:

error:   Plugin admin extensions build failed
[@medusajs/admin-vite-plugin] ENOENT: no such file or directory,
  open '/home/runner/work/yoginii/yoginii/packages/modules/messages/src/admin/__admin-extensions__.js'

The admin bundler always runs, even for plugins that have no admin extensions. It tries to write __admin-extensions__.js into src/admin/. We didn't have that folder. ENOENT. Turbo killed the entire build — 8 other tasks aborted as collateral.

The fix: touch src/admin/.gitkeep. Seriously. Check @yoginii/core — it does exactly this for exactly the same reason.

Ten minutes of log reading for a one-byte file. That's open-source archaeology — the documented conventions are often "whatever the first production plugin did." We added the fix, noted it in the README as a gotcha, and shipped.

The Guest Order Problem (Or: Why Contact Seller Was Broken)

Three days after shipping vendor + storefront inboxes, the client tested a guest checkout end-to-end and reported the "Contact seller" button did nothing useful. Guests could place orders fine. But the confirmation page was a dead end — no way to message the vendor about the order they just placed, because POST /store/conversations requires a customer session.

That led us into guest-to-customer promotion. Two tiers:

  • Tier 1 (inline on confirmation page): a "Create your account" card shows up for guests. Pre-fills email + name from the order. One password field. Submit fires register + customer.create + login via the Medusa SDK, then consumes a claim token to transfer the order. Redirects to /user/orders/:id. Conversion happens at the highest-intent moment.
  • Tier 2 (magic link in email): Resend sends a branded email with a /account-setup?order_id=X&token=Y link. Token is single-use, 30-day TTL, SHA-256-hashed in the DB. Click → land on the same form pre-filled from the token verify endpoint → same submit flow.

We built a separate Medusa module (order-claim) with one table (order_claim_token), a two-workflow set, three routes (request-token, verify, complete), and a subscriber on order.placed that fires the magic-link email only for guest orders.

Why not use Medusa's built-in requestOrderTransferWorkflow? Because it's designed for customer-to-customer transfers with a mandatory accept step and its own confirmation email. For guest-to-customer with a token we already verified, it's the wrong primitive. We verified this against the docs MCP before writing our own.

From /yoginii/messages to @aumiqx/medusa-plugin-messages

With the feature stable on Yoginii staging, the question was: could we ship this as a community plugin?

We scanned the module for coupling:

  • @mercurjs/framework import in the email-fallback subscriber (used fetchStoreData)
  • entity: "seller" in query graphs — the Mercur seller module
  • /vendor/* routes that resolve seller_id from the Mercur seller.members link
  • A Resend template registered inside the Mercur Resend plugin

Three of those are MercurJS-specific. A generic Medusa user who npm installs the plugin wouldn't have any of it.

The extraction

  1. Copied the core to a new repo: /Users/axit/Dev/web/fullstack/medusa-plugin-messages/
  2. Kept: the three tables, the three workflows, the SSE infra, the broadcast subscriber, the admin and store routes, the ensureAdminParticipant helper.
  3. Dropped: seller-utils.ts, all /vendor/* routes, the email-fallback subscriber.
  4. Moved the dropped logic into README "Extensions" recipes — copy-paste examples users can add in their own plugin.
  5. Added package.json with the @aumiqx scope, proper exports map for Medusa plugin conventions, peer deps, and publishConfig.access: "public".
  6. Wrote a 200-line README with install, API reference, extension recipes, and a "scaling beyond one process" section.
  7. MIT license. CHANGELOG pinning v0.1.0-alpha.0. GitHub Actions publish.yml that routes -alpha tags to npm's alpha dist-tag so nobody accidentally installs pre-release as latest.
  8. gh repo create aumiqx/medusa-plugin-messages --public --source=. --push.

Yoginii keeps using the local workspace dep for now. Once @aumiqx/medusa-plugin-messages has a couple of weeks of real-world shakedown, we'll swap the workspace dep for the npm package.

Installing and Using It

For anyone building on Medusa v2 who wants to drop this in:

npm install @aumiqx/medusa-plugin-messages
# or
yarn add @aumiqx/medusa-plugin-messages

Register in medusa-config.ts:

export default defineConfig({
  plugins: [
    { resolve: "@aumiqx/medusa-plugin-messages", options: {} },
  ],
})

Run migrations:

npx medusa db:migrate

That's it. You immediately get:

  • GET/POST /admin/conversations + per-conversation endpoints
  • GET/POST /store/conversations + per-conversation endpoints
  • GET /*/conversations/:id/stream for SSE real-time push
  • A MessagesModuleService you can resolve in your own workflows and subscribers
  • Three event constants (messages.conversation.created, messages.message.created, messages.conversation.read) to wire your own notifications

Frontend integration

Each conversation list item comes back with unread_count pre-computed, participants listed, last_message_at for sorting. The SSE stream emits message.created events you listen for and trigger a refetch (or invalidate your react-query cache). Polling fallback is whatever interval you pick — we used 5s for open threads, 10s for the list.

For a Next.js storefront with Bearer-in-cookie auth, proxy the SSE stream through a same-origin route handler (code sample in the README). For a vendor portal with Bearer-in-localStorage, use the fetch-based SSE client pattern.

Why This Matters for Medusa + MercurJS Marketplaces Specifically

Most Medusa plugins in the wild are single-actor — they assume the customer or the admin, not both plus a seller. Real marketplaces have three parties and every conversation involves at least two of them.

Our plugin treats "customer" | "seller" | "admin" as first-class actor types. A conversation_participant row can be any of the three. The workflows and SSE stream don't care about auth scope — they care about the participant list. Admin auto-joins any thread they reply to (which we added in v0.1.0 after realizing without it, admin unread tracking was a silent no-op on customer↔vendor threads).

That's the design we wish had existed when we started Yoginii. For MercurJS users especially: add a /vendor/conversations/* route file in your project that resolves the current seller from req.auth_context.actor_id and forwards to the shipped workflows. One small file. Done. The README has the stub.

If you're doing something exotic — custom auth providers, multi-tenant with workspaces, sellers-as-sub-organizations — the plugin doesn't constrain you. It just gives you a solid conversation/message/participant core and leaves routing + auth to your project.

What's Next (And How to Contribute)

v0.1.0-alpha.0 is shipped. The backlog for v0.2 is already honest-to-god open issues:

  • Redis pub/sub adapter so the in-process message bus scales horizontally. The current interface is three methods — publish, subscribe, and an unsubscribe cleanup. Drop-in.
  • Attachments — wire through Medusa's file module abstraction so users can plug any file provider (S3, R2, local, GCS).
  • Pagination beyond 500 messages per thread. Current hard cap is fine for most marketplaces but won't survive a long customer-support relationship.
  • pg_trgm full-text search on message bodies.
  • Typing indicators — maybe. The user-value-to-complexity ratio is lower than the rest.

The repo is at github.com/aumiqx/medusa-plugin-messages. Issues welcome. PRs welcome. If you're running it in production and it breaks for you, we want to hear specifically how — we've tested on Yoginii's traffic patterns, not yours.

Why open source

Every marketplace messaging system is ~70% the same plumbing. There's no competitive moat in building it a sixth time. The TalkJS alternative conversation on Medusa Discord comes up monthly and the answer is usually "just pay for TalkJS" — which, fine, but it's a tax on your growth.

This is our contribution to Medusa's ecosystem. The code is boring on purpose. Read it, fork it, rip the parts you want, replace the parts that don't fit your auth model. MIT says you can.

If it saves you a week of building and a monthly SaaS invoice, that's the win.

Key Takeaways

  1. 01Replaced TalkJS across admin, vendor portal, and storefront in 7 days on a live Medusa v2 + MercurJS marketplace.
  2. 02Three tables (conversation, conversation_participant, message), three workflows, zero external SaaS dependency.
  3. 03Server-Sent Events for real-time push. Polling (5–10s) as the safety net. No WebSockets needed.
  4. 04Three auth models, three different SSE client strategies: cookie EventSource (admin), fetch-SSE with Bearer header (vendor), same-origin Next.js proxy (storefront).
  5. 05Unread counts are derived from last_read_at — no denormalized counter, no cache invalidation.
  6. 06Verified every non-trivial API shape against the Medusa v2 docs MCP before writing code. Saved three days of debugging.
  7. 07Extracted the generic core to @aumiqx/medusa-plugin-messages on npm under MIT.
  8. 08Mercur-specific bits (vendor routes, email fallback subscriber) stayed out of the plugin — they're copy-paste recipes in the README.

Frequently Asked Questions

Related Guides