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

Arkadiusz Wawrzyniak
4/7/2025 • 5 views

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:
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:
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 infetchSellerByAuthActorId
checks if they're actually a sellerquery.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):
// 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 providedmetadata
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:
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 databaseREMOTE_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:
// 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
// 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:
// 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
andvalidateAndTransformQuery
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:
// 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:
// 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 backendqueryKey
helps React Query cache and refresh data properly
2. Query Key Factory
This might seem weird at first, but it helps organize your data:
// 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:
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):
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
withzodResolver
makes sure the form data is validuseCreateShippingProfile
sends the data to our backend when the form is submitted
How It All Works Together
- User fills out form: They enter a name and type, then hit Save
- Form validation: The zod schema makes sure everything's valid
- API request: Data gets sent to our
/vendor/shipping-profiles
endpoint - Backend processing: The POST handler validates it again, runs the workflow, saves the profile
- 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:
if (!seller) {
return res.status(401).json({ message: "Unauthorized" });
}
3. Think About Performance
- Use
limit
andoffset
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
Author