HomeBlogImplementing New Functionality in a Custom Medusa JS Marketplace: A Comprehensive Guide

Implementing New Functionality in a Custom Medusa JS Marketplace: A Comprehensive Guide

Arkadiusz Wawrzyniak

Arkadiusz Wawrzyniak

4/7/2025 • 5 views

Implementing New Functionality in a Custom Medusa JS  Marketplace: A Comprehensive Guide

Introduction

Hey there! If you are wondering what is Medusa JS, it is an open-source e-commerce platform. It is a base from which you can customise it in whichever direction you want.
In my case it is a multivendor markeplace, which creates its specific needs. Working which such a large codebase is intimidating, but by the end, you'll get how to design APIs, hook them up to your database, build a nice frontend, and keep your code clean. Let's get started!



So What Are We Building?

Picture this: you've got a marketplace where different sellers can list their products, and each seller needs their own shipping options - maybe free shipping, flat rates, whatever works for them. In Medusa, we call these "shipping profiles," and I'm going to show you how to let vendors create and manage them.

We'll cover:

  • Backend stuff: Making API routes, setting up data structures, and handling all the logic
  • Frontend stuff: Fetching data and making it look good on screen
  • Why we do things this way: Understanding the weird Medusa patterns that grinded my brain at first

Let's start with the backend!

The backend is where all the magic happens behind the scenes. It's how data flows from when someone clicks a button to when it's saved in your database. Here's how we break it down:

bash
apps/backend/src/api/vendor/shipping-profiles/
├── route.ts              # Main routes
├── [id]/                # ID-specific routes
│   └── route.ts         # ID-specific endpoints
├── middlewares.ts       # Authentication
├── validators.ts        # Input validation
├── query-config.ts      # Query parameters
└── schemas/             # Data schemas
    ├── shipping-profile.schema.ts
    └── shipping-profile-response.schema.ts

1. API Routes and OpenAPI

Medusa uses something called OpenAPI Specification (OAS) for defining APIs. It's basically just a fancy way to document what your API does. Here's what it looks like for our shipping profiles:

typescript
import { AuthenticatedMedusaRequest, MedusaResponse } from '@medusajs/framework'
import { ContainerRegistrationKeys } from '@medusajs/framework/utils'
import { fetchSellerByAuthActorId } from '../../../../shared/infra/http/utils'
import { VendorUpdateShippingProfileType } from '../validators'
import {
  deleteShippingProfileWorkflow,
  updateShippingProfilesWorkflow
} from '@medusajs/medusa/core-flows'

/**
 * @oas [get] /vendor/shipping-profiles/{id}
 * operationId: "VendorGetShippingProfile"
 * summary: "Get a Shipping Profile"
 * description: "Retrieves a Shipping Profile."
 * x-authenticated: true
 * parameters:
 *   - in: path
 *     name: id
 *     required: true
 *     description: The ID of the Shipping Profile.
 *     schema:
 *       type: string
 * responses:
 *   "200":
 *     description: OK
 *     content:
 *       application/json:
 *         schema:
 *           type: object
 *           properties:
 *             shipping_profile:
 *               $ref: "#/components/schemas/ShippingProfile"
 * tags:
 *   - Shipping Profile
 * security:
 *   - api_token: []
 *   - cookie_auth: []
 */
export const GET = async (
  req: AuthenticatedMedusaRequest,
  res: MedusaResponse
) => {
  try {
    const seller = await fetchSellerByAuthActorId(req.auth_context?.actor_id, req.scope);
    if (!seller) {
      return res.status(401).json({ message: "Unauthorized" });
    }

    const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
    const { data: [shipping_profile] } = await query.graph({
      entity: "shipping_profile",
      fields: req.queryConfig.fields,
      filters: { id: req.params.id },
    }, { throwIfKeyNotFound: true });

    res.json({ shipping_profile });
  } catch (error) {
    res.status(500).json({ message: "Error fetching shipping profile" });
  }
};


Why OAS?

  • It's like documentation made simpler: That big comment block actually turns into nice API docs if you use tools like Swagger
  • It's super clear: Anyone can look at it and see "oh, this is a GET request to /vendor/shipping-profiles/{id}"
  • Everyone's on the same page: Your teammates won't have to guess what your code does or you if code solo like me, it is easier to remember what is going on

What's going on in the code?

  • AuthenticatedMedusaRequest just makes sure the person is logged in
  • fetchSellerByAuthActorId checks if they're actually a seller
  • query.graph grabs the shipping profile from the database

2. Schemas and Validation

Schemas are just rules for your data. They make sure nobody sends you garbage. Here's a simple one (in my specific case here I just want a string without restricting, but you can restrict this to predefined options):

typescript
// validators.ts
import { z } from "zod";

export const VendorCreateShippingProfile = z.object({
  name: z.string().min(1, "Name is required"),
  type: z.string().optional().default("default"),
  metadata: z.record(z.unknown()).optional(),
}).strict();

export type VendorCreateShippingProfileType = z.infer<typeof VendorCreateShippingProfile>;

Why use schemas?

  • They catch bad data: If someone tries to create a profile without a name, it'll fail right away
  • TypeScript loves them: Your IDE will show errors if you mess up
  • They're self-documenting: It's obvious what fields are required

What's happening here?

  • z.string().min(1) means "this has to be a string with at least one character"
  • type is optional and defaults to "default" if not provided
  • metadata is where you can stick extra stuff if you need to

3. Workflows and Business Logic

Medusa uses these things called workflows for complex operations. Here's how we create a shipping profile:

typescript
export const POST = async (
  req: AuthenticatedMedusaRequest<VendorCreateShippingProfileType>,
  res: MedusaResponse
) => {
  const seller = await fetchSellerByAuthActorId(req.auth_context?.actor_id, req.scope);
  if (!seller) {
    return res.status(401).json({ message: "Unauthorized" });
  }

  const { result } = await createShippingProfilesWorkflow(req.scope).run({
    input: {
      data: [{
        name: req.validatedBody.name,
        type: req.validatedBody.type,
      }],
    },
  });

  await req.scope.resolve(ContainerRegistrationKeys.REMOTE_LINK).create({
    [SELLER_MODULE]: { seller_id: seller.id },
    [Modules.FULFILLMENT]: { shipping_profile_id: result[0].id },
  });

  res.status(201).json({ shipping_profile: result[0] });
};

Why workflows?

  • They're like building blocks: You can reuse them for different features
  • They handle errors nicely: Less try/catch blocks for you
  • They're future-proof: Easy to add more steps later

What's it doing?

  • createShippingProfilesWorkflow saves our profile to the database
  • REMOTE_LINK connects the profile to the seller - basically saying "this seller owns this profile"

4. Module Links

Medusa is built with these modules - think of them like Lego blocks. We need to connect them:

typescript
// seller-shipping-profile.ts
import { defineLink } from "@medusajs/framework/utils";
import FulfillmentModule from "@medusajs/medusa/fulfillment";
import SellerModule from "../modules/seller";

export default defineLink(
  SellerModule.linkable.seller,
  FulfillmentModule.linkable.shippingProfile
);


Why links?

  • They define relationships: This says "a seller can have shipping profiles"
  • They keep modules independent: Each module focuses on one thing, but they can talk to each other

5. Query Configs - Your Data's Gatekeeper

typescript
// query-config.ts
export const vendorShippingProfileFields = [
  'id',
  'name',
  'type',
  'created_at',
  'updated_at',
  'deleted_at',
  'metadata'
]

export const vendorShippingProfileQueryConfig = {
  list: {
    defaults: vendorShippingProfileFields,
    isList: true,
    pagination: {
      limit: 10
    }
  },
  retrieve: {
    defaults: vendorShippingProfileFields,
    isList: false
  }
}

What's it doing?

  • Default fields: vendorShippingProfileFields lists all the fields we want to return by default
  • List config: The list setting handles when we fetch multiple profiles and includes pagination (10 items per page)
  • Retrieve config: The retrieve setting is for when we get just one profile

It might not seem important, but these configs are lifesavers! They keep your API responses consistent and help with performance by not returning unnecessary data.

6. Middlewares - The Security Guards

Middlewares are like bouncers at a club - they check if you're allowed in before you reach the actual route handler. Here's what our shipping profiles middleware looks like:

typescript
// middlewares.ts
import {
  validateAndTransformBody,
  validateAndTransformQuery
} from '@medusajs/framework'
import { MiddlewareRoute } from '@medusajs/medusa'
import {
  checkResourceOwnershipByResourceId,
  filterBySellerId
} from '../../../shared/infra/http/middlewares'
import { vendorShippingProfileQueryConfig } from './query-config'
import {
  VendorCreateShippingProfile,
  VendorGetShippingProfileParams,
  VendorUpdateShippingProfile
} from './validators'


const checkShippingProfileOwnership = () => {
  return async (req, res, next) => {
    const { id } = req.params
    if (!id) return next()

    const sellerId = req.context?.seller?.id
    if (!sellerId) return next()

    const manager = req.scope.resolve('manager')
    const repo = manager.getRepository('seller_seller_shipping_profile')

    const sellerProfile = await repo.findOne({
      where: {
        seller_id: sellerId,
        shipping_profile_id: id,
        deleted_at: null
      }
    })

    if (!sellerProfile) {
      return res.status(404).json({
        message: `Shipping profile with id ${id} was not found`
      })
    }

    next()
  }
}

export const vendorShippingProfilesMiddlewares: MiddlewareRoute[] = [
  {
    method: ['GET'],
    matcher: '/vendor/shipping-profiles',
    middlewares: [
      validateAndTransformQuery(
        VendorGetShippingProfileParams,
        vendorShippingProfileQueryConfig.list
      )
      // No filterBySellerId here as we'll handle that in the route handler
    ]
  },
  // ...other routes
]

What's happening here?

  • Custom ownership checker: checkShippingProfileOwnership makes sure a seller can only access their own profiles
  • Validation: validateAndTransformBody and validateAndTransformQuery use our schemas to check inputs
  • Route matching: Each middleware is tied to specific HTTP methods and URLs

I learned this the hard way - without good middlewares, sellers could potentially see each other's data! These security checks are crucial.

7. Registering It All

The last piece of the puzzle is registering all these middlewares in the main app. You might think "why another file?" but trust me, this keeps things clean:

typescript
// Main middleware registration

export const vendorMiddlewares: MiddlewareRoute[] = [
  {
    matcher: '/vendor*',
    middlewares: [vendorCors]
  },
  /**
   * @desc Here we are authenticating the seller routes
   * except for the route for creating a seller
   * and the route for accepting a member invite
   */
  {
    matcher: '/vendor/sellers',
    method: ['POST'],
    middlewares: [
      authenticate('seller', ['bearer', 'session'], {
        allowUnregistered: true
      })
    ]
  },
  {
    matcher: '/vendor/invites/accept',
    method: ['POST'],
    middlewares: [authenticate('seller', ['bearer', 'session'])]
  },
  {
    matcher: '/vendor/*',
    middlewares: [
      unlessBaseUrl(
        /^\/vendor\/(sellers|invites\/accept)$/,
        authenticate('seller', ['bearer', 'session'], {
          allowUnregistered: false
        })
      )
    ]
  },
  // All the feature-specific middlewares
  ...vendorMeMiddlewares,
  ...vendorSellersMiddlewares,
  // ... many other middlewares
  ...vendorShippingProfilesMiddlewares, // Our new addition!
]

Frontend Time!

Now let's build the part users actually see. We'll use React, React Query, and break everything into components.

1. API Hooks

We use React Query to fetch data from our backend. It's so much easier than plain fetch calls:

typescript
// shipping-profiles.tsx
import { useQuery } from "@tanstack/react-query";
import { fetchQuery } from "../../lib/client";
import { shippingProfileQueryKeys } from "../../lib/query-key-factory";

export const useShippingProfiles = (query?: { limit?: number; offset?: number }) => {
  const { data, ...rest } = useQuery({
    queryFn: () => fetchQuery("/vendor/shipping-profiles", { query }),
    queryKey: shippingProfileQueryKeys.list(query),
  });
  return { ...data, ...rest };
};

Why hooks:

  • They do the heavy lifting: Loading states, error handling, all handled for you
  • Use them anywhere: Drop useShippingProfiles into any component that needs the data

What's it doing?

  • fetchQuery makes HTTP requests to our backend
  • queryKey helps React Query cache and refresh data properly

2. Query Key Factory

This might seem weird at first, but it helps organize your data:

javascript
// query-key-factory.ts
export const shippingProfileQueryKeys = {
  list: (query?: object) => ["shipping_profile", "list", query],
  detail: (id: string, query?: object) => ["shipping_profile", "detail", id, query],
};

Why?

  • Smart caching: React Query uses these keys to store data efficiently
  • Easy updates: When data changes, you can tell React Query to refresh just what's needed

3. Component Structure

Here's how you can organize the frontend code:

bash
vendor-panel/src/
├── hooks/api/shipping-profiles.tsx  # API hooks
├── routes/shipping-profiles/        # Route components
│   ├── shipping-profiles-list/      # List all profiles
│   │   ├── index.tsx
│   │   └── components/
│   ├── shipping-profile-create/     # Create a new profile
│   │   ├── index.tsx
│   │   └── components/
│   └── shipping-profile-detail/     # View/edit a profile
│       ├── index.tsx
│       └── components/
└── lib/query-key-factory.ts         # Query keys

And here's a form for creating a shipping profile (minus actual form layout):

typescript
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"

import { Form } from "../../../../../components/common/form"
import {
  RouteFocusModal,
  useRouteModal,
} from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useCreateShippingProfile } from "../../../../../hooks/api/shipping-profiles"

const CreateShippingOptionsSchema = zod.object({
  name: zod.string().min(1),
  type: zod.string().min(1),
})

export function CreateShippingProfileForm() {
  const { t } = useTranslation()
  const { handleSuccess } = useRouteModal()

  const form = useForm<zod.infer<typeof CreateShippingOptionsSchema>>({
    defaultValues: {
      name: "",
      type: "",
    },
    resolver: zodResolver(CreateShippingOptionsSchema),
  })

  const { mutateAsync, isPending } = useCreateShippingProfile()

  const handleSubmit = form.handleSubmit(async (values) => {
    await mutateAsync(
      {
        name: values.name,
        type: values.type,
      },
      {
        onSuccess: ({ shipping_profile }) => {
          toast.success(
            t("shippingProfile.create.successToast", {
              name: shipping_profile.name,
            })
          )

          handleSuccess(
            `/settings/locations/shipping-profiles/${shipping_profile.id}`
          )
        },
        onError: (error) => {
          toast.error(error.message)
        },
      }
    )
  })

   return (
    <form onSubmit={onSubmit}>
      <Heading>{t("Create Shipping Profile")}</Heading>
      <div>
        <label>{t("Name")}</label>
        <Input {...form.register("name")} />
      </div>
      <div>
        <label>{t("Type")}</label>
        <Input {...form.register("type")} />
      </div>
      <Button type="submit" isLoading={isPending}>
        {t("Save")}
      </Button>
    </form>
  )
}

Why organize it this way?

  • Everything has its place: Each folder does one specific thing
  • Reuse components: Things like <Input> can be used all over your app

What's happening?

  • useForm with zodResolver makes sure the form data is valid
  • useCreateShippingProfile sends the data to our backend when the form is submitted

How It All Works Together

  1. User fills out form: They enter a name and type, then hit Save
  2. Form validation: The zod schema makes sure everything's valid
  3. API request: Data gets sent to our /vendor/shipping-profiles endpoint
  4. Backend processing: The POST handler validates it again, runs the workflow, saves the profile
  5. Response comes back: The new profile data gets cached by React Query and shown on screen

Some Tips I Learned the Hard Way

1. Use TypeScript Everywhere: It catches so many dumb mistakes before they happen. Worth the extra typing!

2. Always Handle Errors: Nothing's worse than a unnoticed bugs:

typescript
if (!seller) {
  return res.status(401).json({ message: "Unauthorized" });
}

3. Think About Performance

  • Use limit and offset for pagination
  • Cache data with React Query so you're not making the same request over and over

4. Don't Skip Security

  • x-authenticated: true (in oas) makes sure random people can't access your endpoints
  • Always validate inputs so nobody can send weird data to your API

Why So Many Files?

I know it seems like a lot of files for one feature, but trust me:

  • Routes: Separate files keep endpoints organized
  • Validators: Reuse your schemas across different routes
  • Middlewares: Handle auth and validation in one place
  • Query Configs: Set defaults for fields and pagination

It feels like overkill at first, but when your app grows, you'll be glad you set it up this way.

Wrapping Up

So there you have it! Adding new features to a Medusa marketplace can be a handfull, but gets clearer once you will break it down. You design APIs with OAS, validate data with schemas, handle logic with workflows, and build a nice frontend with React Query.

Each piece works together to create something that's both powerful for users and manageable for developer.

Happy coding! 😊

Arkadiusz Wawrzyniak

Arkadiusz Wawrzyniak

Author