Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

Stop Hardcoding Delivery Fees: Keeping Store Settings in the Database with Next.js

Every store starts the same way. Somebody says "free delivery over a certain amount", and a developer writes a constant at the top of a file. A month later the number changes, then the delivery fee changes, then there is

Every store starts the same way. Somebody says "free delivery over a certain amount", and a developer writes a constant at the top of a file. A month later the number changes, then the delivery fee changes, then there is a holiday notice, and suddenly a one-line business decision needs a pull request, a review and a deploy.

On ramo.co.il, a Next.js store for home and garden tools, we decided early that anything which changes with a business decision lives in the database. Code only holds secrets and tracking IDs. Here is how that looks in practice.

What counts as a "setting"

My rule of thumb: if the owner or ops person might ask "can you change that?", it is a setting. For us that means:

  • delivery fees and free-delivery thresholds
  • minimum order amounts
  • pickup options and their notices
  • banners and opening hours
  • contact details and shop texts

If it is a secret or an environment-specific ID, it stays in env variables. Everything else goes in a table.

A simple table

One key/value table with a JSON column is enough to start:

create table site_settings (
  key text primary key,
  value jsonb not null,
  updated_at timestamptz not null default now()
);

Keys look like delivery.fee or delivery.freeThreshold. A small admin screen edits rows, so nobody needs to touch the code.

A typed loader with caching and a fallback

The tricky part is not storing the settings, it is reading them without hitting the database on every request, and without the store falling over if the database hiccups.

import { unstable_cache } from "next/cache";

type DeliverySettings = {
  fee: number;
  freeThreshold: number;
  notice: string | null;
};

const FALLBACK: DeliverySettings = {
  fee: 0,
  freeThreshold: 0,
  notice: null,
};

export const getDeliverySettings = unstable_cache(
  async (): Promise<DeliverySettings> => {
    try {
      const rows = await db.siteSettings.findMany({
        where: { key: { startsWith: "delivery." } },
      });
      const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
      return {
        fee: Number(map["delivery.fee"] ?? FALLBACK.fee),
        freeThreshold: Number(map["delivery.freeThreshold"] ?? FALLBACK.freeThreshold),
        notice: (map["delivery.notice"] as string) ?? null,
      };
    } catch (err) {
      console.error("settings load failed", err);
      return FALLBACK;
    }
  },
  ["delivery-settings"],
  { tags: ["settings"], revalidate: 300 }
);

Three things matter here:

  1. Types at the edge. Raw JSON from the database is converted into a typed object once, so the rest of the app never guesses.
  2. A fallback. If the read fails, the page still renders. Choose fallbacks carefully: a missing fee should fail safe, not silently give away free shipping.
  3. Tag-based revalidation. When someone saves a setting in the admin screen, we call revalidateTag("settings"), so the change shows up without a deploy.
"use server";
import { revalidateTag } from "next/cache";

export async function saveSetting(key: string, value: unknown) {
  await db.siteSettings.upsert({
    where: { key },
    update: { value },
    create: { key, value },
  });
  revalidateTag("settings");
}

Validate before you save

Once non-developers can edit values, someone will eventually type "ten" into a number field. We validate every setting with a schema before it is written, so the database only holds values the code can read. Also keep an audit trail of who changed what; it saves arguments later.

Keep the cart and the server in agreement

A subtle bug is showing one threshold in the cart and charging with another. The rule we follow: the server computes the delivery fee from the same loader, and the client only displays what the server returns. One source of truth, no duplicated constants in components.

What we like about it

  • Business changes happen without waiting for a release cycle.
  • Developers stop being a bottleneck for copy and price tweaks.
  • Code reviews focus on logic instead of numbers.
  • Fewer "hotfix, just change this value" deploys.

What to watch for

  • Settings sprawl. Group keys by area and delete the unused ones.
  • Cache lag. Decide how stale is acceptable, and revalidate on save.
  • Testing. Seed sensible defaults in local and test environments so tests do not depend on production data.

None of this is fancy, and that is the point. A boring settings table, a typed loader and a cache layer removed a whole category of small deploys for us. If you run a store and still have fees hardcoded somewhere, it is a pleasant refactor to start with.

How do you handle business configuration in your projects: database, a CMS, or feature-flag tooling? I would like to hear what has worked for you.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.