Skip to content

A cart that won't check out until the prescription is real

An online pharmacy where a prescription is a first-class record with its own lifecycle, and prescription-only items simply can't leave the cart without one.

MortarleafSearch medicines and health productsRARuth A.Cart3
Everyday healthVitaminsSkin & hairAllergyPain reliefDigestionPrescription medicines Free shipping on lines that can ship
Everyday health

Health products and prescription medicines in one catalog · showing 8

Add to cartbuy nowRequestneeds an approved prescriptionSort: popular

Filter

Clear

Category

Vitamins1Skin & hair2Allergy1Pain relief1Digestion1Heart & respiratory2

How you buy it

Buy now5Prescription only3

How Request works

  1. Send a prescriptionA photo, or from your doctor
  2. A pharmacist reviewsSee its state any time
  3. Approved lines check outThe rest never waits

Skin & hair

Anti-dandruff shampoo

250ml bottle

$6.95In cart
Add to cart

Vitamins

Vitamin D3 1,000 IU tablets

90 tablets

$7.45In cart
Add to cart
Prescription only

Heart & blood pressure

Amlodipine 5mg tablets

28 tablets

$8.60if approvedIn cart · held
Request

Allergy

Cetirizine 10mg allergy tablets

30 tablets

$3.49
Add to cart
Prescription only

Respiratory

Albuterol 100mcg inhaler

200 doses

$7.90if approved
Request

Skin & hair

Emollient cream

500g tub

$9.99
Add to cart
Prescription only

Digestion

Omeprazole 20mg capsules

28 capsules

$6.40if approved
Request

Pain relief

Ibuprofen 200mg tablets

16 tablets

$2.15
Add to cart

How the work was scoped

Industry
Healthcare & Dental Practices
Duration
20 weeks
Cooperation model
Fixed price, phased
Services
Storefront engineeringPrescription workflowGraphQL API
Integrations
Payment gatewayIdentity verificationTransactional emailDelivery partner API
Technologies
Next.jsTypeScriptApollo GraphQLNode.jsMUIPostgreSQL
Team
1 Project lead2 Full-stack engineers1 Product designer1 QA engineer

Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.

The hard problem

Most pharmacy storefronts treat a prescription as a file upload at checkout. The order exists before anyone has looked at the prescription, and canceling it later means a refund and an apology. The regulated item and the shampoo sit in the same cart, and only one of them can be sold on demand.

Prescriptions were a file uploaded at checkout, so the order was created and paid for before a pharmacist had looked at anything. When a prescription was rejected, the fix was a refund, a cancellation and an apology, and the customer had already been charged. The counter's most frequent call was people asking whether theirs had been approved yet.

A prescription modeled as its own record with a state (submitted, under review, approved, rejected) that the cart reads. A prescription-only line simply can't be checked out until its prescription is approved, so the order never exists in an invalid state.

How the pieces fit

  1. 01

    Modeled the prescription as an entity with a lifecycle of its own

    The lifecycle was agreed with the pharmacist-in-charge before any storefront work, because everything downstream is a consequence of that model.

  2. 02

    Made the cart read prescription state per line

    The cart reads prescription state per line, which is what makes partial checkout expressible at all.

  3. 03

    Blocked checkout at the line level, so the rest of the cart still moves

    Gating at the line means the shampoo checks out while the regulated item waits, and the waiting line converts to fulfillment when approval lands.

  4. 04

    Gave the customer a prescriptions area where state is visible without contacting the pharmacy

    Customers see the state of their own prescriptions and the reason for any rejection, which was the counter's most-asked question.

  5. 05

    Used GraphQL so the storefront asks for exactly the prescription state it needs

    GraphQL lets the cart ask for prescription state without loading the order graph, which is what removed the slow-cart problem at its cause.

Introduction

The system we were asked to build

The pharmacy sells ordinary health products and prescription-only medicines through one storefront. We built the catalog, the cart, and the prescription workflow that has to sit between them.

A pharmacy selling ordinary health products and prescription-only medicines through one storefront, where the two can't be sold the same way and the customer puts them in the same cart. The engagement began after canceling improperly created orders had become routine enough to need its own process.

Full-Stack Engineering

A prescription is an object

It has an identity and a lifecycle, so it can be reviewed and referred to long after the order.

A prescription is a record with an identity and a lifecycle: submitted, under review, approved, rejected. That means it can be referred to, reused across orders, reviewed independently of any cart, and audited afterward. The old attachment model made every one of those impossible, which is why it was replaced first.

What shipped
  • A record with an identity and a lifecycle
  • Reviewable and reusable independently of any order
  • Auditable after the fact
Prescriptions RX-2026-04795

A record with its own identity and lifecycle · referred to by orders, never attached to one

Approved

The record

Identity, not an attachment

Record

RX-2026-04795

Patient

Ruth Adeyemi

Prescriber

Dr. E. Whitlock · Fernhill Family Medicine

Issued

Sep 10, 2026

Item

Albuterol 100mcg inhaler

Quantity

2 inhalers · one per order

Arrived

Photo upload, before any cart

Decision

Approved by I. Hale

Lifecycle

Four states · the cart reads this
SubmittedSep 11 · 6:02 PMby the customer
Under reviewSep 12 · 9:15 AMopened by I. Hale
ApprovedSep 12 · 9:19 AMstamped I. Hale
RejectedNot takencarries a reason and a route

Reviewed on its own, before any cart existed. A cart line is only ever a reader of this state.

SubmittedUnder reviewApprovedRejected

Referred to by

Reused across orders
ML-2026-30862Sep 12, 2026 · line 1 · 1 inhalerShipped
ML-2026-30901Sep 16, 2026 · line 2 · 1 inhalerShipped
Dispensed on this prescription2 of 2
What it makes possibleAttachmentRecord
Referred to later
Reused across orders
Reviewed without a cart
Audited afterward

Audit trail

Every change, with who made it
WhenEventByDetail
Sep 11, 6:02 PMSubmittedRuth AdeyemiPhoto from My prescriptions · no cart
Sep 12, 9:15 AMOpenedI. HaleState → under review
Sep 12, 9:19 AMApprovedI. HaleKey A · 2 inhalers, one per order
Sep 12, 9:19 AMCustomer shownSystemMy prescriptions → approved
Sep 12, 9:41 AMReferencedSystemML-2026-30862 · line 1
Sep 12, 3:02 PMDispensedI. Hale1 of 2
Sep 16, 10:07 AMReferencedSystemML-2026-30901 · line 2
Sep 16, 1:30 PMDispensedK. Ward2 of 2
On screen

A prescription as a record in the pharmacy console: its own identity, the four-state lifecycle it moved through, the two orders that reused it, and an audit trail naming who did what.

MortarleafSearch medicines and health productsRARuth A.Cart3
Everyday healthVitaminsSkin & hairAllergyPain reliefDigestionPrescription medicines Free shipping on lines that can ship
Your cart

3 lines · each line checked on its own, so what’s ready can go now

2 ready1 waiting on a prescription

Lines

Updated 2:20 PM
Anti-dandruff shampoo250ml bottle · no prescription needed1Ready to check out$6.95
Vitamin D3 1,000 IU tablets90 tablets · no prescription needed1Ready to check out$7.45
Amlodipine 5mg tabletsPrescription only28 tablets · prescription RX-2026-048171Waiting on prescription$8.60not charged

Prescription state · read for this line only

Submitted 9:12 AM today
Submitted
Under review
Approved

This line checks out on its own once a pharmacist approves it. The rest of your cart doesn’t wait.

Removing the waiting line is never needed to check out the othersMy prescriptions

Cart rules, line by line

Evaluated per line
Anti-dandruff shampooNo prescription neededready
Vitamin D3 1,000 IU tabletsNo prescription neededready
Amlodipine 5mg tabletsNeeds RX approved · is submittedheld

Order summary

Ready to check out

Anti-dandruff shampoo$6.95
Vitamin D3 1,000 IU tablets$7.45
48-hour tracked shippingFree
Tax included$2.40

Waiting on a prescription

Amlodipine 5mg tablets · $8.60$0.00 now
Pay today$14.40
Check out 2 ready lines · $14.40

The waiting line isn’t in this payment. It becomes its own fulfillment when approved.

Why nothing else waits

Gated per lineShampoo and vitamins go now; the regulated line waits.
The order is never invalidUnapproved lines are held: not charged, not shipped.
Approval ships itThe waiting line converts into its own fulfillment.
On screen

Gating at the line: two lines are ready for $14.40 while the third waits on its prescription, uncharged, with the cart rules evaluated line by line.

Line-level gating

The shampoo checks out while the regulated item waits. The order never exists in an invalid state.

Gating happens per line: the shampoo and the vitamins check out immediately while the regulated item waits on its prescription. The order simply can't be created with an unapproved prescription-only line on it, so the invalid state never has to be handled. It doesn't exist. A waiting line converts into its own fulfillment the moment approval lands.

What shipped
  • Per-line gating; unregulated items check out immediately
  • The invalid order state is unrepresentable
  • Approval converts the waiting line into fulfillment automatically

State without a phone call

Customers see whether a prescription is under review or approved, which is the counter's most-asked question.

The customer sees exactly which state their prescription is in and what happens next, because "is it approved yet?" was the counter's most-asked question and every instance of it was a phone call. Rejections carry the reason and the route to fix it, so nobody is left wondering whether to resubmit or call.

What shipped
  • Prescription state visible to the customer at all times
  • Rejections carry a reason and a route to resolve
  • Removed the counter's single most-asked question
2:32
My prescriptionsRA
1Submitted1Under review1Approved1Rejected
RX-2026-04817Under review

Amlodipine 5mg tablets × 28

Opened by a pharmacist at 2:28 PM

Submitted
Under review
Approved

Held on order ML-2026-30914. Nothing charged for it yet.

RX-2026-04836Submitted

Omeprazole 20mg capsules × 28

Received today at 2:31 PM

Held in your cart until a pharmacist has checked it.

RX-2026-04795Approved

Albuterol 100mcg inhaler × 2

Approved Sep 12, 2026 · Used on 2 orders. Nothing left to dispense on it.

RX-2026-04702Rejected

Naproxen 250mg tablets × 56

Why: The photo cuts off the prescriber's signature.

Rejected Aug 21, 2026 · Nothing was charged.

Upload a new photoAsk your doctor
On screen

The customer's own prescriptions area, where submitted, under review, approved and rejected are visible without calling the counter, and the rejection carries its reason and a route to fix it.

Review queue

Prescriptions waiting on a decision, each shown against the cart line it holds

Updated 2:30 PM
9waiting · longest first Longest wait firstAll sources Every decision stamped with the pharmacist who made it

Waiting

Image, item and history open beside
WaitedPrescriptionHeld cart lineState
5h 18msince 9:12 AMR. AdeyemiRX-2026-04817Amlodipine 5mg tablets × 28ML-2026-30914 · line 3Under review
4h 43msince 9:47 AMD. KowalczykRX-2026-04819Beclomethasone 100mcg inhalerML-2026-30917 · line 2Submitted
4h 05msince 10:25 AMM. OseiRX-2026-04820Lansoprazole 15mg capsules × 28ML-2026-30921 · line 1Submitted
3h 28msince 11:02 AMS. WhitfieldRX-2026-04823Levothyroxine 50mcg tablets × 28ML-2026-30926 · line 1SubmittedSupply ends Sep 19
2h 42msince 11:48 AMA. BrennanRX-2026-04825Atorvastatin 20mg tablets × 28ML-2026-30929 · line 4Submitted
2h 00msince 12:30 PML. MistryRX-2026-04826Metformin 500mg tablets × 56ML-2026-30933 · line 2Submitted
1h 25msince 1:05 PMJ. FairbairnRX-2026-04828Amoxicillin 500mg capsules × 21ML-2026-30936 · line 1SubmittedAcute
49msince 1:41 PMP. LindqvistRX-2026-04829Mometasone 50mcg nasal sprayML-2026-30940 · line 3Submitted
21msince 2:09 PMT. NakamuraRX-2026-04831Sertraline 50mg tablets × 28ML-2026-30943 · line 2Submitted
No order records opened to find a prescriptionJ next K previous

RX-2026-04817

Opened 2:28 PM by you
Fernhill Family Medicine09/15/2026
Patient: Ruth AdeyemiAmlodipine 5mg tabletsTake one daily · qty 28 (twenty-eight)
Dr. E. Whitlock
Photo upload · 9:12 AM
Amlodipine 5mg tablets · 28 tabletsML-2026-30914 · line 3Held · not charged

Ruth Adeyemi · earlier prescriptions

Sep 12, 2026Albuterol 100mcg inhaler × 2Approved
Aug 21, 2026Naproxen 250mg tablets × 56Rejected
Jul 18, 2026Amlodipine 5mg tablets × 28Approved
Approve A Reject with reason R

Will be stamped Imogen Hale · Sep 17, 2026 · approval converts the held line to fulfillment

Decided this session

2:26 PMRX-2026-04812ApprovedLine converted to fulfillmentI. Hale
2:21 PMRX-2026-04810RejectedNot signed by the prescriberI. Hale
2:15 PMRX-2026-04806ApprovedLine converted to fulfillmentI. Hale

A real review queue

On screen

The pharmacist works a queue: nine waiting, longest first, each against its held cart line, with the photo, item and history open and decisions made from the keyboard and stamped by name.

Pharmacists work a dedicated queue, with no digging through order records.

Pharmacists work a queue ordered by wait time and urgency, with the prescription image, the item and the customer's history on one screen. Nobody opens order records one at a time to find the prescriptions inside. Approve and reject are keyboard actions, because this work happens in sessions, and every decision is stamped with the pharmacist who made it.

What shipped
  • A queue ordered by wait and urgency
  • Image, item and history on one screen
  • Keyboard decisions, each stamped with the pharmacist

Precise queries

GraphQL means a screen asks for the prescription state it needs instead of over-fetching an order graph.

GraphQL lets each surface ask for exactly what it needs: the cart asks for prescription state and nothing else, while the review queue asks for the image and the history. Over-fetching an entire order graph to read one field was what had been slowing carts down, and changing the query shape fixed it at the cause. No caching layer papering over the same problem.

What shipped
  • Each surface requests exactly the fields it renders
  • The cart reads prescription state without loading the order graph
  • Fixed by query shape, with no cache papering over it
Schema & queries

GraphQL · each surface asks for exactly the fields it renders

Apollo · one schema
CartCartLineGatesReview queueReviewQueueItemMy prescriptionsPrescriptionStatusOrder viewOrderLineStates
cart-line-gates.graphqlCart
1# Cart: prescription state, nothing else
2query CartLineGates($cart: ID!) {
3 cart(id: $cart) {
4 lines {
5 id
6 prescription {
7 id
8 state
9 }
10 }
11 }
12}
Selects 3 fields · no order, no customer, no payments

Who asks for what

Same schema
Cartprescription { state }
Review queueprescription { image item history }
My prescriptionsprescription { state reason }
Order viewlines { state fulfillment }

Response

What the cart renders from
{
"data": {
"cart": {
"lines": [
{ "id": "ln_1", "prescription": null },
{ "id": "ln_2", "prescription": null },
{
"id": "ln_3",
"prescription": {
"id": "RX-2026-04817",
"state": "SUBMITTED"
}
}
]
}
}
}

Gate, per line

ln_1Anti-dandruff shampooReady
ln_2Vitamin D3 1,000 IUReady
ln_3Amlodipine 5mgHeld

The graph it doesn't load

Fields, not tables
cart
lines
id
productcatalog asks
prescription
id · state
image · item · historyqueue asks
reasonMy prescriptions asks
ordernot loaded
customer · addresses
payments · refunds
fulfillments · shipments
lines · pricing · stock
events

Fixed by query shape

The cart used to read a whole order graph to find one status field. Asking for just the field fixed that at the cause, with no cache over it.

On screen

The cart's GraphQL query asking for prescription state and nothing else, the response each line is gated from, and the order graph it no longer loads.

Process

Phase by phase

  1. Phase 1: The Prescription Object

    Not An Upload Field

    Modeled the prescription with its own identity and lifecycle, so it can be reviewed, approved, rejected and referred to later. It no longer lives as a file stapled to an order.

    • Prescription Entity
    • State Machine
    • Review Queue
  2. Phase 2: The Cart

    Per Line, Not Per Order

    Made the cart evaluate prescription requirements line by line, so a customer buying shampoo and a prescription medicine can complete the part that's ready.

    • Line-Level Gating
    • Split Checkout
    • Cart Rules
  3. Phase 3: The Customer's View

    State Without A Phone Call

    Built the prescriptions area so a customer can see whether theirs is under review or approved, which is the question the pharmacy counter otherwise answers all day.

    • My Prescriptions
    • Status Surface
    • Notifications
  4. Phase 4: The API

    Ask For What You Need

    Used GraphQL so the storefront requests exactly the prescription state a screen needs, without over-fetching an order graph to find one status field.

    • GraphQL Schema
    • Prescription Queries
    • Client Cache
MortarleafSearch medicines and health productsRARuth A.Cart3
Everyday healthVitaminsSkin & hairAllergyPain reliefDigestionPrescription medicines Free shipping on lines that can ship
Checkout
Cart2Delivery3Payment4Placed
2 of 3 lines in this checkout

Delivery for the 2 lines that can ship

Chosen for these lines only
Anti-dandruff shampooVitamin D3 1,000 IU tablets

How they travel

48-hour shippingShips today, arrives Sat, Sep 19Free
Next-day shippingOrder by 4 PM, arrives Fri, Sep 18$3.95
Pick up at the pharmacyReady after 4 PM todayFree
Ruth Adeyemi14 Rowan Street, Columbus, OH 43215Change

Not in this delivery

Excluded, not delayed
Amlodipine 5mg tabletsPrescription onlyWaiting on RX-2026-04817 · no delivery chosen for itExcluded · $0.00

When a pharmacist approves the prescription, this line becomes its own fulfillment. Until then it isn't shipped or charged.

Paying today

Anti-dandruff shampoo$6.95
Vitamin D3 1,000 IU tablets$7.45
48-hour shippingFree
Tax included$2.40
Total$14.40
Amlodipine 5mg tablets (held)$0.00

When you place the order

2 lines shipping 1 line held on its prescription $0.00 charged for the held line
Continue to payment · $14.40

Your prescription

RX-2026-04817
Submitted
Under review
Approved

Follow it in My prescriptions. No need to call the pharmacy.

On screen

Delivery is chosen only for the lines that can actually ship; the waiting line is explicitly excluded, never silently delayed, and the order is previewed as it will be placed: two lines shipping, one held, nothing charged for it.

Outcome

What it carries now

0

Orders created in an invalid state

−64%

Counter calls asking for status

4

Prescription states modeled

Never

Cart blocked entirely

Orders created in an invalid state is zero by construction: the state is unrepresentable, so there's nothing to measure. Counter calls asking for status compares the pharmacy's own call log before and after. Prescription states modeled is a count. Cart blocked entirely is a statement about the line-level gate.

Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.

What it settled

What the architecture settled

A prescription as a file upload means the order exists before anyone has checked it.

Uploading at checkout inverts the order of operations: the money moves before the check, and every rejection becomes a refund where it should have been a decline.

Gate at the line. Blocking a whole cart over one item loses the rest of the sale.

Blocking the whole cart is the obvious gate and loses the unregulated half of the sale, which at this pharmacy was most of the cart value.

Most counter calls are status questions, and a status surface answers them without staff.

The status calls weren't a communication failure. They were a missing surface: customers had no way to see a state that already existed in the system.

GraphQL earns its place when screens need a few fields from a large graph.

Asking for a few fields from a large graph is exactly the case GraphQL is for, and here it fixed a real performance problem, not just a tidy-up.

Ways of working

How the work was run

The decision that shaped everything was made in the first two weeks: gate at the line, not the order. It's a small modeling choice that turns a blocked cart into a partial checkout, and it's why the storefront stopped losing the unregulated half of every mixed order.

Phased over twenty weeks, with the regulated half settled before the storefront half. The line-level gate was the decision that made the commercial case work. Blocking the whole cart was the obvious answer, and it would have lost the unregulated items with it, which at this pharmacy is most of the cart value. Pharmacists worked the review queue in a pilot before it replaced the old process.

One held line, four states

The prescription decides one line. The rest of the cart has already gone.

One order: shampoo and vitamins checked out for $14.40, and a blood pressure medicine waiting on its prescription. Put that prescription in each of its four states and see what the order’s lines, the charge and the customer’s own status view do. Switch tabs, or use the arrow keys once one is focused.

Prescription RX-2026-04817 · times illustrative

  1. Submitted (current)
  2. Under review
  3. Approved

The prescription exists as its own record before anyone has looked at it. The order was created with the regulated line held outside the charge and the delivery, so the shampoo and the vitamins are already shipping.

The order’s linesML-2026-30914

  • Anti-dandruff shampooNo prescription neededShipping
  • Vitamin D3 1,000 IU tabletsNo prescription neededShipping
  • Amlodipine 5mg tabletsRX-2026-04817 · No delivery chosen, not chargedHeld · waiting on prescription

The other two lines checked out in every state.

The charge

Two ready lines, at placement
$14.40
The held line
$0.00
Nothing charged while it waits
Charged so far
$14.40

Refunds needed: none

What the customer sees

RX-2026-04817Submitted

Received

Waiting for a pharmacist. Your other two items are already on their way.

Submitted 9:12 AM

Visible in My prescriptions, so the answer doesn’t need a call to the counter.

Why the order is never invalid: the cart reads the prescription’s state line by line, so a prescription-only line that isn’t approved can only ever be on an order as held: not charged, not delivered. Approval is the only thing that converts it.

Architecture

From a prescription arriving to the line it releases

The lifecycle was agreed with the pharmacist-in-charge before any storefront work, because everything downstream reads it: the queue writes it, the cart gates on it, and the customer sees it.

  1. 01 · Source
    Prescription submittedCreated as a record with its own identity, separate from any order, so it can be reviewed independently of any cart.
  2. 02 · Queue
    Pharmacist review queueOrdered by wait and urgency, with image, item and history on one screen. Every keyboard decision is stamped with the pharmacist.
  3. 03 · State
    Lifecycle in PostgreSQLFour states: submitted, under review, approved, rejected. The record can be reused across orders and audited afterward.
  4. 04 · Engine
    Cart rules, per lineA prescription-only line can't check out unapproved, so the invalid order is unrepresentable. Approval converts the line into its own fulfillment.
  5. 05 · Delivery
    GraphQL to the storefrontEach surface asks for exactly the fields it renders: the cart reads prescription state without loading the order graph.

What can’t happen to a prescription-only line

Dispensing, charging & status

Nothing charged or sent before approval

Gating is per cart line. A prescription-only line that isn't approved stays held: no charge, no delivery. The order never exists in an invalid state, so a rejection is a simple decline, with no refund or cancellation.

Every decision has a name on it

Pharmacists work a queue with the image, the item and the customer's history on one screen, and each approve or reject is stamped with the pharmacist who made it. The prescription record can be audited afterward.

State visible without a phone call

Customers see submitted, under review, approved or rejected in their own prescriptions area, and a rejection carries its reason and the route to fix it. Counter calls asking for status fell 64% in the pharmacy's own call log.

Selling prescription-only medicines in the same cart as everything else? Scope your build in 3 minutes.

Scope your build
Have a project?

Let's talk

Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.