OpticWorks Store Development Guide
This guide covers setting up your local development environment for both the Next.js storefront and Medusa backend.
Prerequisites
Section titled “Prerequisites”- Node.js: 20.x or later
- pnpm: 8.x or later
- Docker: For local PostgreSQL and Redis
- Stripe CLI: For webhook testing
- Git: Version control
Quick Start
Section titled “Quick Start”1. Clone the Repository
Section titled “1. Clone the Repository”git clone https://github.com/r-mccarty/opticworks-store.gitcd opticworks-store2. Install Dependencies
Section titled “2. Install Dependencies”# Install pnpm if needednpm install -g pnpm
# Install all dependenciespnpm install3. Start Infrastructure
Section titled “3. Start Infrastructure”# Start PostgreSQL and Redisdocker compose up -d
# Verify services are runningdocker compose ps4. Configure Environment
Section titled “4. Configure Environment”# Copy environment templatescp apps/storefront/.env.example apps/storefront/.env.localcp packages/medusa-backend/.env.example packages/medusa-backend/.env
# Edit with your local values5. Initialize Database
Section titled “5. Initialize Database”# Run migrationscd packages/medusa-backendpnpm medusa migrations run
# Seed with sample datapnpm seed6. Start Development Servers
Section titled “6. Start Development Servers”# From root directorypnpm dev
# Or start individually:# Terminal 1: Backendcd packages/medusa-backend && pnpm dev
# Terminal 2: Storefrontcd apps/storefront && pnpm dev7. Access the Application
Section titled “7. Access the Application”- Storefront: http://localhost:3000
- Medusa Admin: http://localhost:9000/admin
- Medusa API: http://localhost:9000
Environment Configuration
Section titled “Environment Configuration”Storefront (.env.local)
Section titled “Storefront (.env.local)”# Medusa APINEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000MEDUSA_API_KEY=your-api-key
# StripeNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...STRIPE_SECRET_KEY=sk_test_...
# Analytics (optional)NEXT_PUBLIC_GA_ID=G-XXXXXXXXMedusa Backend (.env)
Section titled “Medusa Backend (.env)”# DatabaseDATABASE_URL=postgres://postgres:postgres@localhost:5432/medusaREDIS_URL=redis://localhost:6379
# JWTJWT_SECRET=your-jwt-secretCOOKIE_SECRET=your-cookie-secret
# StripeSTRIPE_API_KEY=sk_test_...STRIPE_WEBHOOK_SECRET=whsec_...
# EasyPostEASYPOST_API_KEY=EZ...EASYPOST_WEBHOOK_SECRET=...
# ResendRESEND_API_KEY=re_...RESEND_FROM_EMAIL=noreply@optic.worksDevelopment Workflow
Section titled “Development Workflow”Project Structure
Section titled “Project Structure”opticworks-store/├── apps/│ └── storefront/ # Next.js frontend│ ├── app/ # App Router pages│ ├── components/ # React components│ ├── lib/ # Utilities│ └── public/ # Static assets├── packages/│ └── medusa-backend/ # Medusa customizations│ ├── src/│ │ ├── api/ # Custom endpoints│ │ ├── jobs/ # Background jobs│ │ ├── services/ # Business logic│ │ └── subscribers/ # Event handlers│ └── medusa-config.ts├── e2e/ # Playwright tests├── infra/ # Deployment configs├── docker-compose.yml└── pnpm-workspace.yamlCommon Commands
Section titled “Common Commands”# Developmentpnpm dev # Start all servicespnpm dev:frontend # Start storefront onlypnpm dev:backend # Start backend only
# Testingpnpm test # Run all testspnpm test:e2e # Run Playwright testspnpm test:unit # Run unit tests
# Buildingpnpm build # Build all packagespnpm build:frontend # Build storefrontpnpm build:backend # Build backend
# Lintingpnpm lint # Lint all codepnpm format # Format with Prettier
# Databasepnpm db:migrate # Run migrationspnpm db:seed # Seed sample datapnpm db:reset # Reset databaseStorefront Development
Section titled “Storefront Development”Creating New Pages
Section titled “Creating New Pages”import { getProduct } from '@/lib/medusa';import { ProductDetail } from '@/components/product/ProductDetail';
interface Props { params: { handle: string };}
export async function generateMetadata({ params }: Props) { const product = await getProduct(params.handle); return { title: product.title, description: product.description, };}
export default async function ProductPage({ params }: Props) { const product = await getProduct(params.handle);
return <ProductDetail product={product} />;}Using Server Actions
Section titled “Using Server Actions”'use server';
import { revalidatePath } from 'next/cache';import { medusa } from '@/lib/medusa/client';import { getCartId } from '@/lib/cart';
export async function addToCart(variantId: string, quantity = 1) { const cartId = await getCartId();
await medusa.carts.lineItems.create(cartId, { variant_id: variantId, quantity, });
revalidatePath('/cart');}
export async function updateQuantity(lineId: string, quantity: number) { const cartId = await getCartId();
if (quantity === 0) { await medusa.carts.lineItems.delete(cartId, lineId); } else { await medusa.carts.lineItems.update(cartId, lineId, { quantity }); }
revalidatePath('/cart');}Adding Components
Section titled “Adding Components”'use client';
import { useState, useTransition } from 'react';import { Button } from '@/components/ui/button';import { addToCart } from '@/lib/actions/cart';import { toast } from 'sonner';
interface Props { variantId: string; available: boolean;}
export function AddToCartButton({ variantId, available }: Props) { const [isPending, startTransition] = useTransition();
const handleClick = () => { startTransition(async () => { try { await addToCart(variantId); toast.success('Added to cart'); } catch (error) { toast.error('Failed to add to cart'); } }); };
return ( <Button onClick={handleClick} disabled={!available || isPending} className="w-full" > {isPending ? 'Adding...' : available ? 'Add to Cart' : 'Out of Stock'} </Button> );}Backend Development
Section titled “Backend Development”Creating Custom Endpoints
Section titled “Creating Custom Endpoints”import { MedusaRequest, MedusaResponse } from '@medusajs/medusa';
export async function GET(req: MedusaRequest, res: MedusaResponse) { const { cart_id } = req.query;
const cartService = req.scope.resolve('cartService'); const easyPostService = req.scope.resolve('easyPostService');
const cart = await cartService.retrieve(cart_id, { relations: ['shipping_address', 'items'], });
const rates = await easyPostService.getRates(cart);
res.json({ rates });}Creating Services
Section titled “Creating Services”import { TransactionBaseService } from '@medusajs/medusa';import EasyPost from '@easypost/api';
class EasyPostService extends TransactionBaseService { private client: EasyPost;
constructor(container) { super(container); this.client = new EasyPost(process.env.EASYPOST_API_KEY); }
async getRates(cart: Cart) { const shipment = await this.client.Shipment.create({ from_address: WAREHOUSE_ADDRESS, to_address: this.formatAddress(cart.shipping_address), parcel: this.calculateParcel(cart.items), });
return shipment.rates.map(rate => ({ id: rate.id, carrier: rate.carrier, service: rate.service, price: parseFloat(rate.rate) * 100, deliveryDays: rate.delivery_days, })); }}
export default EasyPostService;Creating Event Subscribers
Section titled “Creating Event Subscribers”import { SubscriberArgs, SubscriberConfig } from '@medusajs/medusa';
export default async function orderPlacedHandler({ data, container,}: SubscriberArgs<{ id: string }>) { const orderService = container.resolve('orderService'); const resendService = container.resolve('resendService');
const order = await orderService.retrieve(data.id, { relations: ['items', 'customer', 'shipping_address'], });
await resendService.sendOrderConfirmation(order);}
export const config: SubscriberConfig = { event: 'order.placed',};Testing
Section titled “Testing”Running Tests
Section titled “Running Tests”# Unit testspnpm test:unit
# E2E tests (requires running services)pnpm test:e2e
# E2E with UIpnpm test:e2e:uiWriting E2E Tests
Section titled “Writing E2E Tests”import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => { test('complete purchase', async ({ page }) => { // Add product to cart await page.goto('/products/rs-1-sensor'); await page.click('button:has-text("Add to Cart")');
// Go to checkout await page.goto('/checkout');
// Fill shipping info await page.fill('[name="email"]', 'test@example.com'); await page.fill('[name="firstName"]', 'Test'); await page.fill('[name="lastName"]', 'User'); await page.fill('[name="address"]', '123 Main St'); await page.fill('[name="city"]', 'San Francisco'); await page.fill('[name="postalCode"]', '94102');
// Select shipping await page.click('[data-testid="shipping-option-0"]');
// Fill payment (Stripe test card) const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]'); await stripeFrame.locator('[name="cardnumber"]').fill('4242424242424242'); await stripeFrame.locator('[name="exp-date"]').fill('12/30'); await stripeFrame.locator('[name="cvc"]').fill('123');
// Complete order await page.click('button:has-text("Place Order")');
// Verify success await expect(page.locator('h1')).toContainText('Thank you'); });});Stripe Webhook Testing
Section titled “Stripe Webhook Testing”Install Stripe CLI
Section titled “Install Stripe CLI”# macOSbrew install stripe/stripe-cli/stripe
# Linuxcurl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpgecho "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.listsudo apt update && sudo apt install stripeForward Webhooks
Section titled “Forward Webhooks”# Login to Stripestripe login
# Forward webhooks to local serverstripe listen --forward-to localhost:9000/webhooks/stripe
# Note the webhook signing secret (whsec_...)# Add to .env as STRIPE_WEBHOOK_SECRETTrigger Test Events
Section titled “Trigger Test Events”# Trigger a payment succeeded eventstripe trigger payment_intent.succeeded
# Trigger with specific datastripe trigger checkout.session.completed \ --add checkout_session:customer_email=test@example.comDebugging
Section titled “Debugging”Backend Logging
Section titled “Backend Logging”// Enable debug loggingimport { Logger } from '@medusajs/medusa';
class MyService { private logger: Logger;
constructor(container) { this.logger = container.resolve('logger'); }
async doSomething() { this.logger.debug('Starting operation', { context: 'data' }); // ... }}Frontend Debugging
Section titled “Frontend Debugging”// Use React DevTools and Next.js debug modemodule.exports = { logging: { fetches: { fullUrl: true, }, },};Resources
Section titled “Resources”| Resource | Link |
|---|---|
| Medusa Docs | https://docs.medusajs.com |
| Next.js Docs | https://nextjs.org/docs |
| Stripe Testing | https://stripe.com/docs/testing |
| Shadcn UI | https://ui.shadcn.com |