ledsusleme.com sells outdoor LED decorations in Turkish: pole motifs for street lamps, light-up figures, arches, hanging ornaments and LED by the metre. The catalog has 250 products across 32 categories and 750 SKUs. Every SKU has a visible price.
There is no database. There is no checkout, no payment form, no user account and no admin panel. The site is a static Next.js 16 build on Vercel, and every order closes in a WhatsApp chat.
That sounds like a shortcut. For this business it is the right shape. Every item is made to order in a workshop, shipping depends on the size of the piece and the destination city, and installation is quoted separately. A checkout would have to pretend those numbers are known up front. A WhatsApp conversation does not.
This post walks through the parts that make it work, with short excerpts from the real code. Identifiers and comments are partly Turkish; I translate as I go.
TL;DR
-
Catalog: JSON files in
data/catalog/, typed and served through one access module. Every page is statically generated. -
Prices: one file,
data/fiyatlar.json, keyed by SKU, storing the net price (VAT excluded). The VAT-included figure shown on the page is computed at build time. -
Cart: SKU, quantity and metres in
localStorage. Prices are never stored in the cart; they are looked up on every render. -
Order: the cart becomes a pre-filled
wa.memessage, with a compact fallback when the encoded URL gets too long. -
AI agents: the same URL returns Markdown when the client sends
Accept: text/markdown. - Content gate: a prebuild script fails the build when product copy contains a number that is not in the product's own data.
-
Structured data:
ProductGroupwith oneOfferper variant, priced with the same VAT-included number the page shows.
Why no database?
The catalog changes a few times a year. Prices change when exchange rates move. Nothing changes per user, and nothing has to be written at request time. That is a static site's job description.
The dependency list in package.json is three packages:
"dependencies": {
"next": "16.3.8",
"react": "19.3.0",
"react-dom": "19.3.0"
}
No ORM, no CMS client, no validation library, no UI kit. Data lives in the repository as JSON and TypeScript, and a change to the catalog is a commit that goes through the same build as a code change. That build is where the guard rails live.
One price file, net in storage, gross on the page
Turkish price labelling rules expect the consumer price, VAT included, as the headline figure. The business thinks in net prices. So the file stores net, and everything else is derived from it.
data/fiyatlar.json holds one entry per SKU:
"LS-01001": {
"net": 24500,
"source": "approved",
"unit": "adet",
"updatedAt": "2026-10-02"
}
unit is either "adet" (piece) or "m" (metre). The file is produced by a Python script, and a person approves the numbers in a CSV before they land here. Nobody types a price into a page.
lib/pricing.ts is the only reader:
import FIYATLAR from "@/data/fiyatlar.json";
import { site } from "@/data/site";
/** KDV dahil tutar tam liraya yuvarlanır. */
export function gross(net: number): number {
return Math.round(net * (1 + site.terms.vatRate));
}
export function getPrice(sku: string): Price | null {
const e = TABLE[sku];
if (!e || !Number.isFinite(e.net) || e.net <= 0) return null;
const g = gross(e.net);
return { net: e.net, gross: g, vat: g - e.net, unit: e.unit, updatedAt: e.updatedAt };
}
vatRate is 0.2, defined once in data/site.ts next to the warranty length and the return window. A missing or non-positive price returns null, and the page falls back to "ask on WhatsApp" instead of rendering zero.
The page then shows the gross figure large and the net figure under it, followed by one fixed line: installation, removal and shipping are not included. That line is not optional copy. It sits in the price component, so no product can ship without it.
Line totals handle the two units with one branch:
/** Satır tutarı: adet ürününde birim x adet, metre ürününde birim x metre x adet. */
export function lineTotal(price: Price, qty: number, meters?: number): { net: number; gross: number } {
const factor = price.unit === "m" ? (meters ?? 0) * qty : qty;
const net = Math.round(price.net * factor);
return { net, gross: gross(net) };
}
A cart that holds no prices
The cart lives in localStorage under one key. The comment at the top of lib/cart.ts states the rule. In English: the cart never goes to a server and needs no sign-up; only SKU, quantity and metres are kept; the price is read from data/fiyatlar.json on every render, so an old price does not survive a price list update; every function returns a new array.
// localStorage sepeti. Sunucuya hiç gitmez, kayıt gerekmez.
// Sadece sku + adet + metre tutulur; fiyat her render'da data/fiyatlar.json'dan okunur
// (fiyat listesi güncellenince eski fiyat sepette kalmasın diye).
// Tüm fonksiyonlar immutable: yeni dizi döner.
export const CART_KEY = "ls_cart_v1";
A line is four fields:
export interface CartLine {
sku: string;
qty: number;
/** Metre bazlı ürünlerde seçilen uzunluk (m). Adet ürünlerinde yok. */
meters?: number;
/** Müşterinin "özel ölçü" notu. */
note?: string;
}
Keeping prices out of the cart is the important decision. If a visitor adds a motif in September and comes back after a price update in October, the cart shows October's price. There is no stale-price bug to fix because the cart has nothing to go stale.
Everything that comes from outside (storage, or a shared cart URL) goes through sanitize first. The comment on the last constant says control characters, newlines included, must not break the WhatsApp message:
export const MAX_LINES = 50;
export const MAX_METERS = 1000;
const SKU_RE = /^[A-Za-z0-9._-]{1,40}$/;
// Kontrol karakterleri (satır sonu dahil) WhatsApp mesajını bozmasın.
const CONTROL_RE = new RegExp("[\u0000-\u001f\u007f]+", "g");
Unknown shapes are dropped, quantities are clamped to 1-999, invalid metre values remove the line, and notes are cut to 200 characters with control characters replaced. Duplicate lines merge through addLine, which returns a new array instead of mutating the old one.
Sharing a cart without an account
A contractor often builds a list and sends it to the person who pays. With no accounts, the cart itself has to travel. encodeCart turns it into a short query string:
/** Paylaşılabilir sepet: ?c=SKU*2,SKU~12.5*1 (~ metre) */
export function encodeCart(lines: readonly CartLine[]): string {
return lines.map((l) => `${l.sku}${l.meters ? `~${l.meters}` : ""}*${l.qty}`).join(",");
}
Opening /sepet?c=... decodes it, runs it through sanitize, merges it into the local cart and removes the parameter from the address bar.
Where the cart page gets its prices
The cart page is a client component, and I did not want the whole catalog in the client bundle. So a static route builds a compact lookup at build time, app/sepet-veri.json/route.ts:
export const dynamic = "force-static";
export function GET() {
const out: Record<string, [string, string, string, string, string, string, number | null, "adet" | "m", string]> = {};
for (const p of allProducts()) {
for (const v of p.variants) {
const pr = getPrice(v.sku);
out[v.sku] = [p.name, p.slug, v.sizeLabel, v.colorLabel, v.option, v.image.src, pr?.gross ?? null, pr?.unit ?? "adet", p.pricing.unitLabel ?? "adet"];
}
}
return Response.json(out, { headers: { "Cache-Control": "public, max-age=3600, must-revalidate" } });
}
Tuples instead of objects keep the payload smaller: 750 SKUs come to roughly 150 KB before compression. The file comment also notes that internal fields (supplier references, cost data) never enter this payload. The cart fetches it once, resolves each line and computes totals locally.
From cart to WhatsApp message
lib/whatsapp.ts builds wa.me links. It deliberately imports nothing from the catalog or the price module, so it stays small in the client bundle.
A product page button produces a message like this:
export function productMessage(l: WaLine): string {
const lines = [
`Merhaba, ${site.domain} üzerinden yazıyorum.`,
`Ürün: ${l.name} (${l.sku})`,
`Seçim: ${selection(l) || "belirtilmedi"}`,
`Miktar: ${amount(l)}`,
];
if (l.gross) lines.push(`Sayfadaki fiyat: ${tl(l.gross)} (KDV dahil, montaj ve nakliye hariç)`);
// ...
lines.push("Teslimat il/ilçe: ");
return lines.join("\n");
}
Product, SKU, chosen size and colour, quantity, the price the customer saw (VAT included, installation and shipping excluded), and an empty "delivery city/district" line at the end for the customer to fill in. The person answering on the other side gets every fact needed to quote shipping in the first message, which is the whole point.
The cart message has one wrinkle. Turkish characters cost six bytes each once percent-encoded (ı becomes %C4%B1), and very long wa.me URLs are not reliable across clients. So there is a ceiling and a compact mode:
export const WA_MAX_ENCODED = 1800;
export function cartMessage(lines: readonly WaLine[], shareUrl?: string): string {
// ...
const full = (compact: boolean): string => {
const out = [`Merhaba, ${site.domain} sepetimdeki ürünler için sipariş vermek istiyorum.`, ""];
lines.forEach((l, i) => {
if (compact) {
out.push(`${i + 1}) ${l.sku} - ${amount(l)}${l.gross ? ` - ${tl(l.gross)}` : ""}`);
} else {
// ...
}
});
// ...
};
const msg = full(false);
return encodeURIComponent(msg).length > WA_MAX_ENCODED ? full(true) : msg;
}
The compact form keeps SKU, amount and line total per line. The cart page also passes the shareable cart link into the message, so nothing is lost: the person replying can open the link and see the full cart with names and sizes. Both forms are covered in tests/whatsapp.test.ts: Turkish characters survive encoding, an empty selection prints "belirtilmedi" (not specified), a 15-line cart switches to compact mode and stays under the limit, and a short cart stays in full mode.
Markdown for AI agents, on the same URL
Search assistants and agents read pages too, and a product page full of layout markup is a poor input. Every product and category has a Markdown twin, served by a statically generated route handler, app/md/[...path]/route.ts:
export const dynamicParams = false;
export function generateStaticParams() {
return [
{ path: ["index.md"] },
{ path: ["urunler.md"] },
...CATEGORIES.map((c) => ({ path: ["kategori", `${c.slug}.md`] })),
...allProducts().map((p) => ({ path: ["urun", `${p.slug}.md`] })),
];
}
The Markdown is built from the same data as the HTML (lib/markdown.ts), so the variant table with VAT-included prices matches the page exactly.
A separate URL is useful, but agents should not need to know it. proxy.ts (Next.js 16's replacement for middleware.ts) does content negotiation on the normal URL:
export function wantsMarkdown(accept: string | null): boolean {
if (!accept) return false;
const md = q(accept, "text/markdown", false);
return md > 0 && md >= q(accept, "text/html");
}
export function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
const md = markdownPath(pathname);
if (pathname !== "/sepet" && wantsMarkdown(req.headers.get("accept"))) {
const res = NextResponse.rewrite(new URL(md, req.url));
res.headers.set("Vary", "Accept");
res.headers.set("Content-Type", "text/markdown; charset=utf-8");
return res;
}
const res = NextResponse.next();
res.headers.set("Vary", "Accept");
// ...Link header: sitemap, llms.txt and the Markdown alternate
return res;
}
Two details matter. Markdown wins only when the client ranks it at least as high as HTML, and */* does not count as asking for Markdown (the false argument turns off wildcard matching). A browser sending text/html,*/*;q=0.8 never gets Markdown by accident. And the cart page is excluded, because a cart is personal and noindex.
You can try it:
curl -H "Accept: text/markdown" https://www.ledsusleme.com/urun/sacak-led
That returns 200 with Content-Type: text/markdown; charset=utf-8 and a variant table. Without the header, the same URL returns HTML with a Link: </md/urun/sacak-led.md>; rel="alternate"; type="text/markdown" header.
One open issue, honestly reported. The proxy sets Vary: Accept on both branches, because a CDN that caches without it could serve Markdown to a browser. In the live response I checked on 3 October 2026, the Vary header shows only Next.js's own router values (rsc, next-router-state-tree, ...), not Accept. Something downstream replaces the value instead of appending to it. Until that is fixed, the protection against cache mix-ups rests on Vercel's routing, not on the header I meant to send. It is on the fix list.
A content gate that checks numbers against data
Product copy was written partly with AI help. The failure mode I worried about most was not style. It was a confident sentence with a wrong number: a motif described as 120 cm when the data says 100, or a wattage that belongs to a different size.
So scripts/audit-content.ts runs in prebuild, and any finding exits with code 1:
"prebuild": "npx tsx scripts/validate-data.ts && npx tsx scripts/audit-content.ts",
The core is a regular expression that finds every number followed by a unit:
const UNIT_RE = /(\d+(?:[.,]\d+)?)\s*(cm|mm|metre|m|w|watt|kg|v|volt|led|k|tl|yıl|gün|%)(?![a-zçğıöşü])/giu;
For each product, the allowed set is built from that product's own variants: every number in the size label, plus primaryCm, maxCm, wattW, weightKg, voltageV and ledCount. Centimetre values also get their metre equivalents, so "250 cm" and "2,5 m" both pass. A short list of general numbers is allowed everywhere (colour temperatures, common voltages, the warranty years, the return window).
function factGate(text: string, allowed: Set<number>): string[] {
const bad: string[] = [];
for (const m of text.matchAll(UNIT_RE)) {
const n = num(m[1]);
const u = unitKey(m[2]);
if (u === "tl") {
bad.push(`${m[0]} (fiyat metne yazılmaz, tablodan okunur)`);
continue;
}
const general = ALLOWED_NUMBERS[u.toUpperCase()] ?? ALLOWED_NUMBERS[u] ?? [];
if (allowed.has(n) || general.includes(n)) continue;
bad.push(m[0]);
}
return bad;
}
Any amount in lira inside product copy is an error, full stop; the message reads "price is not written in copy, it is read from the table". Prices exist in one place.
The same script runs a few other checks, each one a few lines:
- Banned phrases. A list of Turkish marketing clichés plus the em-dash. The match uses Unicode letter boundaries, because one banned word, "şık" (chic), is also the tail of "ışık" (light), which appears on nearly every page.
- Required sentences. Each product needs a recommendation sentence ("we recommend ... because ..."), at least one "not suitable for" item, and its own size somewhere in the text.
- Near-duplicates. 5-word shingles with Jaccard similarity: below 0.15 between any two products, below 0.20 inside the same category.
- Sentence rhythm. At least 70% of sentences under 20 words, and a standard deviation of sentence length of at least 4, so the copy does not settle into one monotonous length.
A sibling script, scripts/validate-data.ts, checks the data side: every SKU has a positive price, every image exists under public/, slugs and SKUs are unique, and no supplier model name or code leaks into a visible field.
Structured data that matches the page
Each product page emits a ProductGroup with one Product per variant (lib/product-schema.ts). Size and colour drive variesBy, and each variant gets its own Offer:
offers: {
"@type": "Offer",
url: multi ? `${url}?v=${v.sku}` : url,
price: price.gross,
priceCurrency: "TRY",
priceValidUntil: priceValidUntil(price.updatedAt),
availability: "https://schema.org/MadeToOrder",
itemCondition: "https://schema.org/NewCondition",
seller: { "@id": ORG_ID },
hasMerchantReturnPolicy: returnPolicyStandard(),
// ...metre ürünlerinde UnitPriceSpecification, unitCode "MTR"
},
Three choices worth calling out:
-
priceis the gross figure, the same number the visitor sees. Structured data that disagrees with the visible price is worse than none. -
availabilityisMadeToOrder, because nothing is in stock; every piece is built after the order. - There is no
shippingDetails. The comment in the file explains why: shipping is quoted per order on WhatsApp, and a wrong shipping declaration is worse than a missing one.
The 37 products with a single variant skip the group and emit a plain Product.
What I would do differently
-
Fix
Varyfirst, not last. Content negotiation without a workingVaryheader relies on the platform's routing. I would verify the live header on day one. - Generate the cart lookup per category. About 150 KB is acceptable, but a cart rarely touches more than a few categories.
-
Keep the gate's allow-list short. Every general number added to
ALLOWED_NUMBERSis a hole in the fact gate. So far it holds colour temperatures, voltages, the warranty and return windows, and two percentages.
Wrapping up
The site has no database, no checkout and no accounts, and it does not miss them. One JSON file holds every price. The cart stores only what the customer chose, never what it cost, and turns into a WhatsApp message that answers the seller's first questions. The build refuses copy with numbers the data cannot back.
The live site is www.ledsusleme.com. Product pages answer Accept: text/markdown, and the site-wide summary for agents is at /llms.txt.

Top comments (0)