Skip to content

Building a Shop From Scratch

Arrow Left
SHOP
Reservation confirmed
The life of an order: payment confirmed, stock reservation finalized, invoice issued, shipping label created and parcel handed to the carrier.

Most businesses that want to sell online shouldn't build a commerce platform from scratch. I recently did, and it's far more work than the storefront suggests: payments, stock, invoices, shipping and email all have to keep working when nobody is watching.

The better question is how much of the system to own. Use established infrastructure where you can, and save custom work for the places where it creates real business value. This is the guide I wanted when I started: the decisions in order, the realistic options at each step and what I'd pick.

What happens after someone clicks Buy

Pick a step to see both sides of an order.

The customer sees
A payment form, then a confirmation page.
The shop does
Creates a pending order, then confirms it once the payment provider reports success.
What can go wrong
The customer closes the tab before the confirmation page loads.
How to handle it
Trust the provider's verified webhook, not the browser, so the order is still confirmed.

Decide How Much to Build

Start with the question that can save you months. There are four realistic levels of ownership, and each one trades speed for control.

OptionExamplesGood forTrade-off
Hosted platformShopifyLaunching quickly with standard flowsLittle to operate, but custom work ends at the platform's limits
Self-hosted platformWoo­CommerceLow entry cost and many pluginsHosting, updates, security and plugin conflicts become your job
Headless engineMedusa, Saleor, VendureCustom flows on a proven commerce coreSlower launch, a storefront to build and servers to run
Fully customYour own codeRules that no engine can modelSlowest start, and every part of the system is yours to maintain for good

I'd rule out fully custom almost every time, because carts, promotions, taxes and refunds are full of edge cases that an engine has already handled. If standard flows fit your business, a hosted platform is the better answer. A headless engine makes sense when you need to own the experience and the business rules, and you're prepared to operate what that brings with it.

Choose an Engine

Engines differ less in features than in how they feel to extend, so evaluate them with real work. Before committing, build one custom flow in each candidate, such as a special price rule or an extra order step. Then look at:

  • Extensibility: whether the flow fits the engine's extension points, or requires forking the core.
  • Business logic: whether your rules can take part in the engine's order flow while living in your own code.
  • Upgrades: how often releases break extensions, and how clear the migration path is.
  • Documentation and ecosystem: whether the docs answered your questions, and whether the integrations you need are maintained.
  • Long-term cost: what hosting, upgrades and the skills to maintain the engine will cost over several years.

A fork is the expensive outcome here, because you pay for it again at every upgrade.

Build the Storefront

Any modern framework can do this, so pick the one your team knows. The decisions that matter are about caching and search.

Product and category pages are generally good candidates for caching, because they're read far more often than they change. Cart, checkout and account pages are user-specific, so they need dynamic handling and must stay out of shared caches.

Prices and stock are where caching gets risky. Decide early how a change reaches the cache, and invalidate or revalidate only the affected pages, so a bulk import never requires rebuilding the whole storefront. A cached page can still be stale, which is why the server has to check price and availability again at checkout.

For search, let catalog size, filtering needs, typo tolerance, operational effort and cost drive the choice. A database query can be enough for a small catalog. Dedicated engines such as Meilisearch, Typesense and Algolia earn their place as those needs grow, and they differ mainly in hosting model and pricing.

Take Payments Safely

Hosted payment pages and embedded forms keep card data away from your servers, which greatly reduces your PCI DSS scope. They don't remove it: you still have compliance obligations, and the pages that load or link to the form still need protecting. A direct integration gives you full control of the form and adds considerably more security and compliance work. Unless you have a strong reason, use the hosted option.

Customers close tabs mid-payment, so a browser redirect proves nothing. Treat the provider's webhook as the source of truth, and handle every delivery the same way:

  1. Verify the signature before trusting the payload.
  2. Check the order reference, amount and currency against your own order.
  3. Record the event ID, so a duplicate delivery does nothing.
  4. Apply the event only if it's valid for the order's current state, because events can arrive out of order.

Stock needs the same care. Reserve it atomically before the customer pays, so concurrent orders can't claim the same last unit. Confirm the reservation when payment succeeds, and release it when payment fails or expires. Both operations have to be safe to retry.

Responses and webhooks can also be delayed or lost, and a charge can succeed while its response never reaches you. Retrying with a stable idempotency key, where the provider supports one, prevents a second charge. For payments that stay pending, query the provider instead of waiting for a webhook that may not come.

payments.ts
// Every retry of this attempt reuses the same key, so the provider
// can return the first result instead of charging again.
await paymentProvider.createPayment({
  orderId: order.id,
  amount: order.total,
  currency: order.currency,
  idempotencyKey: order.paymentAttemptId,
});

export async function handlePaymentWebhook(body: string, signature: string) {
  // Throws if the signature is invalid.
  const event = paymentProvider.verifyWebhook(body, signature);

  await db.transaction(async (tx) => {
    // A unique constraint on the event ID makes a duplicate a no-op.
    const isNew = await tx.paymentEvents.insertIfAbsent(event.id);
    if (!isNew) return;

    const order = await tx.orders.lockById(event.orderId);
    if (event.amount !== order.total || event.currency !== order.currency) {
      await tx.orders.flagForReview(order.id);
      return;
    }

    // Late or out-of-order events must not move the order backward.
    if (event.type !== "payment.succeeded") return;
    if (order.status !== "pending_payment") return;

    await tx.stock.confirmReservation(order.id);
    await tx.orders.markPaid(order.id);
  });
}

This is the success path only. The transaction covers your own database and nothing else: the provider's charge sits outside it, which is why the idempotency key and the webhook both exist. A production version also handles failures and refunds, and a payment that arrives after its reservation has expired, which has to end in a new reservation or a refund. Invoices and emails belong in a queue written by the same transaction, so a retry can't repeat them.

Model Your Data

If you hold your own stock and your processes are simple, the engine's inventory may be all you need. If you sell from several suppliers, or run pricing rules across thousands of products, a separate back office starts to pay off.

Data moves from external sources through the back office and the engine to the storefront.

An existing ERP is usually sufficient when your purchasing, accounting and fulfillment follow standard processes. A custom back office is justified when the way you buy and price is the business itself, and an ERP would need constant workarounds. You'll maintain it for as long as the shop exists. I built one, and it became the largest part of the project.

Either way, give each source its own adapter and translate everything into one canonical internal model, so the rest of the system never needs to know where a record came from.

source-adapter.ts
interface SyncPage {
  records: SourceRecord[];
  nextCursor: string | null;
}

interface SourceAdapter {
  sourceId: string;
  fetchAll(cursor: string | null): Promise<SyncPage>;
  // Optional, because many sources only offer a full export.
  fetchChanges?(cursor: string): Promise<SyncPage>;
}

The interface is the easy part. Most of the work is in the failure cases:

  • Identifiers: map each source and external ID to one stable internal product ID. Upserting on that pair prevents duplicates when a sync runs again.
  • Full and incremental sync: incremental runs are cheap, but they only see what the source reports. A periodic full sync catches the drift.
  • Cursors: treat them as opaque, and save one only after its page is stored, so a failed run resumes without gaps. Plain timestamps can miss records that change during a run.
  • Discontinued products: a truncated feed can look like a mass deletion, so only act on absence after a complete full sync. Stop selling the product and keep the record, because past orders refer to it.
  • Stock and price: synced values are snapshots. Record when each was read, and decide what happens when a supplier can't fulfill something you've already sold.

Connect Invoices and Shipping

For both, the choice is an aggregator or a direct integration. An aggregator gives you many carriers or a compliant invoicing flow through one API, and it's the faster start. Direct integrations cost more to build, and they can offer better rates and more detailed tracking data.

Invoicing doesn't follow one global model. Tax rules, e-invoicing mandates, document types and retention periods vary by country, so confirm your legal obligations with an accountant before choosing a tool. Carriers vary too: each has its own tracking statuses. Map them to a small set of internal states, keep the raw value for debugging and send unknown statuses to review instead of guessing.

Keep each integration behind its own module, and store its last known state on the order. External services will be slow or unavailable at times, so queue the work and retry with backoff, and make sure the order stays recoverable while it waits.

The hard case is an operation that succeeds while its response is lost. Before retrying, look the record up by your own reference, or use an idempotency key if the service supports one. A scheduled reconciliation against the external system then catches whatever slipped through.

Send Email

Use a transactional provider such as Postmark, Amazon SES or Resend. Deliverability depends on domain authentication, sending and IP reputation, bounce handling and each mailbox provider's policies, and a good provider takes much of that work off your plate.

I run my own mail server. It can work, but it's rarely the best starting point for a business whose focus is selling. Reputation takes time to build, and when a large mailbox provider rejects your mail, there's often little to do beyond fixing what you can and waiting.

Host It

There are three common setups:

  • Managed platforms such as Vercel, Railway or Render need the least operations work and cost more as you grow.
  • A single server with a self-hosted PaaS such as Coolify or Dokploy costs far less. The server is also a single point of failure, and updates, security patches and recovery all become your job.
  • Kubernetes adds resilience and a lot of operational work. For one shop, it's only justified if a team already runs it or you have requirements that simpler setups can't meet.

I'd start with the second if you're comfortable running a server, and the first if you aren't. Either way, plan for losing the server:

  • Take consistent database backups with the database's own tools, not file copies of a running system.
  • Keep encrypted copies off-site, away from the server and ideally with another provider.
  • Test restores on a schedule, and write down the steps while they're fresh.
  • Monitor from outside your infrastructure, so you still get an alert when the whole server is gone.

A Sensible Order

  1. Complete one real order end to end, with a real payment, stock reservation and email.
  2. Import the catalog and get search working.
  3. Integrate invoicing and shipping.
  4. Move the old store's URLs with permanent redirects to preserve search traffic.
  5. Automate only what you've already done by hand a few times.
  6. Add monitoring and test a backup restore before launch.

The first step is the one to protect. Everything after it gets easier once a real order can pass through the whole system.