Café Origen — Tienda Headless con Medusa.js

Migración de Shopify Basic (79€/mes) a stack propio: Medusa 2.x · PostgreSQL · Redis · Next.js 14 · Stripe

Medusa.js 2.x Next.js 14 App Router Stripe Payments TypeScript Estricto 120 SKUs · 18.000€/mes
0%
Comisión por transacción
↓ vs 2% Shopify
120
SKUs migrados
4 variantes × origen
79€
Ahorro mensual
+ sin comisiones Stripe
~90ms
Latencia API P95
Railway · EU West
100%
Control de código
Apache 2.0 license
Arquitectura del Sistema
🌐
Storefront
Next.js 14 · App Router
next@14.2 @tanstack/query Zustand (cart) Tailwind CSS
⚙️
Medusa Backend
API REST · Port 9000
@medusajs/medusa Workflows + Events Custom API Routes Admin :7001
🗄️
Datos
Railway · Managed
PostgreSQL 16 Redis 7 (sessions) Migrations auto
🔗
Integraciones
Plugins oficiales
Stripe (pagos) Holded (facturas) SendGrid (email) Plausible (stats)
Flujo de Checkout — Medusa Workflow con Rollback Automático
01
Crear Carrito
POST /store/carts
02
Añadir Items
/carts/:id/line-items
03
Email + Dirección
PATCH /store/carts/:id
04
Envío
/shipping-methods
05
Pago Stripe
Payment Intent
06
Completar Pedido
order.placed event
medusa-config.ts TypeScript
// Configuración principal: módulos, plugins, DB
import { defineConfig } from "@medusajs/framework"

export default defineConfig({
  projectConfig: {
    databaseUrl: process.env.DATABASE_URL,
    redisUrl: process.env.REDIS_URL,
    http: {
      storeCors: "https://cafenorigen.es",
      adminCors: "https://admin.cafeorigen.es",
    },
  },
  modules: [
    {
      resolve: "@medusajs/payment",
      options: {
        providers: [{
          resolve: "@medusajs/payment-stripe",
          options: {
            apiKey: process.env.STRIPE_SECRET_KEY,
            webhookSecret:
              process.env.STRIPE_WEBHOOK_SECRET,
          },
        }],
      },
    },
    {
      resolve: "@medusajs/fulfillment",
      options: {
        providers: [
          { resolve: "./src/modules/correos-esp" },
          { resolve: "./src/modules/mrw" },
        ],
      },
    },
  ],
})
src/scripts/seed-cafes.ts TypeScript
// Seed: café Colombia con variantes 4×3 (12 SKUs)
const cafes = [{
  title: "Colombia Huila — Geisha Natural",
  description: "Notas de maracuyá, flor de azahar...",
  status: "published",
  options: [
    { title: "Gramaje", values: [
      "250g", "500g", "1kg"
    ]},
    { title: "Molienda", values: [
      "Grano", "Espresso",
      "Filtro", "Moka"
    ]},
  ],
  variants: [
    {
      title: "250g · Grano",
      sku: "COL-HUI-250-GRN",
      prices: [{
        amount: 1490, // 14,90€
        currency_code: "eur"
      }],
      options: {
        Gramaje: "250g",
        Molienda: "Grano"
      },
      manage_inventory: true,
      inventory_quantity: 48,
    },
    // ... 11 variantes más (250/500/1kg × 4 moliendas)
  ],
}]
src/subscribers/order-placed.ts TypeScript
// Suscriptor: al confirmar pedido →
// 1) email de confirmación (SendGrid)
// 2) webhook a Holded para crear factura

import type { SubscriberArgs, SubscriberConfig }
  from "@medusajs/framework"

export default async function orderPlacedHandler(
  { event, container }: SubscriberArgs
) {
  const order = event.data
  const notif = container.resolve("notification")

  // 1. Email confirmación
  await notif.send("order-confirmation-es", {
    to: order.email,
    data: {
      nombre: order.shipping_address.first_name,
      pedido_id: order.display_id,
      items: order.items.map(i => ({
        nombre: i.title,
        cantidad: i.quantity,
        precio: (i.unit_price / 100).toFixed(2) + "€",
      })),
      total: (order.total / 100).toFixed(2) + "€",
    },
  })

  // 2. Webhook Holded — crear factura automática
  await fetch("https://api.holded.com/api/invoices", {
    method: "POST",
    headers: {
      "key": process.env.HOLDED_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      contactName: order.customer?.email,
      date: Math.floor(Date.now() / 1000),
      items: order.items.map(item => ({
        name: item.title,
        units: item.quantity,
        price: item.unit_price / 100,
        tax: 21, // IVA estándar
      })),
    }),
  })
}

export const config: SubscriberConfig = {
  event: "order.placed",
}
src/api/store/custom-search/route.ts TypeScript
// Endpoint de búsqueda personalizado:
// GET /store/cafe-search?q=colombia&molienda=espresso
import type { MedusaRequest, MedusaResponse }
  from "@medusajs/framework"

export async function GET(
  req: MedusaRequest,
  res: MedusaResponse
) {
  const { q, molienda, origen } = req.query

  const productService =
    req.scope.resolve("product")

  const [products, count] =
    await productService.listAndCount(
      {
        q: q as string,
        status: "published",
        tags: [origen, molienda]
          .filter(Boolean) as string[],
      },
      {
        take: 20,
        relations: [
          "variants", "variants.prices",
          "images", "options",
        ],
      }
    )

  res.json({
    products,
    count,
    filters_applied: {
      q: q || null,
      molienda: molienda || null,
      origen: origen || null,
    },
  })
}
storefront/lib/cart.ts — Lógica de carrito para Next.js 14 TypeScript
const MEDUSA_URL = process.env.NEXT_PUBLIC_MEDUSA_URL ?? "https://api.cafeorigen.es" /** Crea carrito con región EUR */ export async function createCart() { const res = await fetch( `${MEDUSA_URL}/store/carts`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ region_id: "reg_eur_es" }), }) return res.json() // → { cart: { id, items: [], total: 0 } } } /** Añadir variante al carrito */ export async function addToCart( cartId: string, variantId: string, qty: number = 1 ) { return fetch( `${MEDUSA_URL}/store/carts/${cartId}/line-items`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ variant_id: variantId, quantity: qty }), } ).then(r => r.json()) }
/** Checkout completo en 4 pasos */ export async function completeCheckout( cartId: string, customer: { email: string; address: Address } ) { // Paso 1: asignar email + dirección await fetch(`${MEDUSA_URL}/store/carts/${cartId}`, { method: "POST", body: JSON.stringify({ email: customer.email, shipping_address: customer.address }) }) // Paso 2: seleccionar envío await fetch( `${MEDUSA_URL}/store/carts/${cartId}/shipping-methods`, { method: "POST", body: JSON.stringify({ option_id: "so_correos_48h" }) }) // Paso 3: inicializar pago Stripe await fetch( `${MEDUSA_URL}/store/carts/${cartId}/payment-sessions`, { method: "POST" }) // Paso 4: completar pedido const res = await fetch( `${MEDUSA_URL}/store/carts/${cartId}/complete`, { method: "POST" } ) const { type, data } = await res.json() // type === "order" → pedido confirmado // type === "cart" → falta algún dato return { type, order: data } }
API Endpoints + Eventos del Sistema
Método Ruta Descripción Evento disparado Status
GET /store/products Listado de cafés con filtros (origen, molienda, precio) ✓ Activo
POST /store/carts Crear carrito con región EUR cart.created ✓ Activo
POST /store/carts/:id/complete Confirmar pedido tras pago Stripe order.placed ✓ Activo
POST /store/cafe-search Búsqueda por q + filtros molienda/origen (custom route) ✓ Activo
GET /admin/orders Panel admin: pedidos con estado fulfillment ✓ Activo
WEBHOOK /webhooks/stripe Confirmar pago y pasar pedido a "paid" payment.captured ✓ Activo
EVENT order.placed Subscriber → email confirmación + factura Holded automática Async subscriber ✓ Activo
EVENT order.fulfillment_created Subscriber → SMS tracking Correos al cliente Async subscriber En progreso
Pipeline CI/CD — GitHub Actions → Railway
.github/workflows/deploy.yml YAML
name: Deploy Medusa
on:
  push:
    branches: [main]

jobs:
  deploy-backend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Railway CLI
        run: npm i -g @railway/cli
      - name: Run DB migrations
        run: railway run npx medusa db:migrate
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
      - name: Deploy backend
        run: railway up --service medusa-backend
  deploy-storefront:
    needs: deploy-backend
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}
        working-directory: ./storefront
docker-compose.dev.yml YAML
version: "3.9"
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: cafeorigen
      POSTGRES_USER: medusa
      POSTGRES_PASSWORD: dev_password
    ports: ["5432:5432"]
    volumes: ["pgdata:/var/lib/postgresql"]

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
    command: redis-server --appendonly yes

  medusa:
    build: .
    ports: ["9000:9000", "7001:7001"]
    depends_on: [postgres, redis]
    env_file: .env.local
    command: npx medusa develop
    volumes: ["./src:/app/src"]

volumes:
  pgdata:
Costes de Infraestructura
Railway Postgres 5€/mes
Railway Redis 5€/mes
Railway Backend 10€/mes
Vercel Storefront 0€/mes
Total infra 20€/mes
Ahorro vs Shopify Plus +59€/mes
Checklist de Seguridad
✓ Admin bajo auth JWT + IP allowlist
✓ CORS restringido a dominio propio
✓ Stripe webhook signature verify
✓ Secrets en Railway env vars
✓ Rate limiting en rutas /store
⏳ WAF (Cloudflare) — próximo sprint