I built powertrack.online (opens in a new tab), a Next.js store that sells SAGA power track sockets across India. A power track is an aluminium rail you twist socket modules into, anywhere along it. By early November the store had taken six orders worth ₹70,650, with no ads, no coupons used and no official launch.
SAGA is a brand of The Sixty One (61 FZCO), where I'm a Software Developer, and I run the store's engineering: storefront, checkout and payments, admin and search. We haven't pulled the switch yet. First we want to know how much traffic it can take.
This case study is the build log: what had to be true for a stranger to find the store and trust it with money, and the rule I kept from each thing that broke. It isn't a growth-hack story, and it has no traffic numbers.
The numbers so far

| What | Where it stands |
|---|---|
| Orders | 6, for 20 units, none cancelled |
| Revenue | ₹70,650, an average of ₹11,775 |
| Coupons used | None |
| Ads | None; no ad pixel or tag manager on the home page |
| Time spent | A rebuild from 7 June: about two months in two stretches, plus October's search and payment work |
| Tests | 1,043, all passing, in 2.64 s |
At least four of the six orders came in before the October search work. The orders screen can't tell me what brought any of them, so I don't credit any to search.
Getting found without ads
I did the search work in October, and its first problem was the name. As the store's SEO notes record, a search for "power track" in India returns Powertrac tractors. "PowerTrack" also names unrelated software and energy products.
Titles, landing pages and guides now say "power track socket" or name the type, like "surface power track". "SAGA PowerTrack" stays as the brand name, as in the title template, %s | SAGA PowerTrack. The /shop title went from "Shop PowerTrack Sockets Online | Best Prices Pan India" to "Shop Power Track Sockets & Adaptors". Some CMS headings and descriptions haven't caught up yet. Before you pick keywords, search your category word in the market you sell in.
Then each search got one URL and a title that says it back:
| Page | Title |
|---|---|
| Home | Buy Power Track Sockets Online in India |
| /shop/surface-power-track | Surface Power Track Sockets (500 & 1000 mm) |
| /shop/recessed-power-track | Recessed Power Track Sockets (Flush-Mount) |
| /shop/power-track-adaptors | Power Track Adaptor Modules & USB-C |
| /power-track-price-india | Power Track Socket Price in India (2026 List) |
| /power-track-for-kitchen | Power Track Sockets for Kitchens |
"Near me" searches tempt you into a page per city. With the same products, prices and delivery promise everywhere, those pages would have been near-duplicates, so I never built them.
One where to buy (opens in a new tab) hub lists 11 delivery regions with their PIN code prefixes and says we deliver to other valid Indian PIN codes too. Its FAQ answers "Is there a power track socket shop near me?" One hub with real regional facts beats a page per city with the name swapped.
Eight new guides each target one search, and eight older CMS posts got new title tags and descriptions on their old URLs. Because product names in the CMS still say "Powertrack", every product page gets a hand-written title tag, like "SAGA S 500 Surface Power Track Socket 500 mm". A test checks each one: at most 65 characters, starting "SAGA " and containing "Power Track Socket" or "Power Track Adaptor". In the Shopping feed, adaptor titles add "for SAGA Power Track" to keep them out of travel-adapter searches.
Written for AI answer engines too
The same pages had a second reader: AI answer engines such as ChatGPT, Perplexity and Google's AI Overviews. I can't make an answer engine quote a page, only remove the reasons it wouldn't. It has to fetch the page, find the answer and trust it.
Fetch. One set of robots.txt rules covers every crawler, and AI crawlers get the same access as Googlebot. Pages render on the server, with the FAQ answers in the HTML before any JavaScript runs.
Find. Each landing page and guide opens with its answer. The kitchen page's first paragraph opens: "A kitchen power track socket puts movable sockets along the counter, so the socket goes to the appliance and not the other way round." A googlebot meta tag allows full snippets and large image previews (max-snippet:-1, max-image-preview:large).
Trust. FAQPage markup is built from the list the page renders. "From" prices are computed from the live catalogue: on 9 November the price page said "Today, SAGA tracks start at ₹7,925, adaptor modules at ₹1,426.50, combos at ₹4,510.80", and nobody typed those numbers. Every product's offer points to one store entity with a stable @id. All 15 prices on the price page matched the feed, and tests fail any landing page, category page or guide that hard-codes a ₹ figure.

Google limited FAQ rich results to well-known government and health sites in August 2023 (opens in a new tab) and stopped showing them (opens in a new tab) from 7 May 2026. Here the FAQPage markup gives search and AI engines machine-readable answers, and that's all I expect from it. I haven't built an llms.txt, AI-crawler rules or citation tracking yet.
Bugs that would have cost rankings
Search work meant reading the store as a crawler does: raw HTML, status codes, robots.txt and sitemaps. Little of what I found showed in a browser.
Canonicals that pointed at another company's site. Production fell back to a hard-coded site URL that belonged to another company, so every canonical, both sitemaps and robots.txt sent Google there. One origin module now builds those URLs and refuses that company's domain. If your code has a fallback site URL, check whose site it is.
A canonical on a shared layout. /returns, /faqs, /about and other pages without their own canonical inherited the home page's, telling Google they were copies of it. The layout now keeps only the base URL, and each page names itself:
// app/layout.tsx: no `alternates` here, or every page inherits the home canonical
export const metadata = { metadataBase: new URL(SITE_URL) };
// app/returns/page.tsx
export const metadata = { alternates: { canonical: "/returns" } };Metadata streamed into <body>. Next.js 15.2 streams metadata for bots it thinks run JavaScript, Googlebot and Storebot-Google among them. They could get the canonical after </head>, where Google ignores it (opens in a new tab). One config option, htmlLimitedBots (opens in a new tab), keeps the metadata in <head> for every visitor, bot or browser:
// next.config.ts
const nextConfig = {
// otherwise Next streams metadata into <body> for bots it thinks run JS
htmlLimitedBots: /.*/,
};
export default nextConfig;On 9 November the home page's <title> sat at byte 5,431 and its canonical at 6,205, both before </head> at 8,193. After every framework upgrade, read the raw HTML a crawler gets.
Skeletons that turned 404s into 200s. In July I added loading.tsx skeletons, which wrap each page in Suspense. The status code goes out as soon as streaming starts, so a notFound() inside came too late. A missing product showed a "not found" page but sent a 200, the code for a page that's fine. Now a layout above the skeleton checks the record first, and deleted products return a real 404 with noindex.
A redirect from a page under a skeleton has the same problem: it goes out as a 200 with a meta refresh. So the redirects the October work needed, from the old /shop?category= links to the new category pages, run in middleware as permanent 308s that keep the query string: /shop?category=adaptors&utm_source=test lands on /shop/power-track-adaptors?utm_source=test. Decide the status code before anything streams.
A shopping crawler stuck in the catch-all. Robots.txt had groups for Googlebot, Googlebot-Image and Bingbot, plus a catch-all that blocked the site's scripts. Google obeys one group per crawler (opens in a new tab), so Storebot-Google, the Shopping crawler, got only the catch-all, though it needs those scripts to check products and checkout. One group now covers every crawler and blocks only private areas. Give a bot its own group only if it needs different rules.
A shop page with no products in its HTML. /shop filtered its products in the browser, so a crawler reading the HTML found none. Now /shop renders its products on the server, and each of the four categories has its own static page. Render on the server whatever a crawler should see.
A sitemap where everything changed today. Static pages carried the time the sitemap was built as their lastmod, which Google uses only when it's consistently accurate. Now 44 of the 54 URLs carry a real edit date and the other 10 carry none. Leave lastmod out unless you know the real date.
The Shopping feed needed the same facts as the page:
- Prices go out exact to the paisa, as they now do in the page's markup, which used to round ₹14,953.50 to ₹14,954.
- Hard-coded "Free shipping" copy gave way to one delivery module that reads the same live settings checkout uses. The feed's handling and transit times add up to the window the page shows.
- Each colour is its own item: 33 items on 9 November, 14 in stock and 19 out.

Caching that tells the truth
In July I changed the CMS fetch setting from 0 to 60 seconds, the store's shared refresh time, and it stayed at 60 for 76 days before I traced the problem. The Sanity loader, @sanity/next-loader 1.3.7, builds Next's cache option in one line that mixes ?? and ?:, and in production it turned my 60 into "cache forever":
// ?? binds tighter than ?:, so this reads as (revalidate ?? isProd) ? false : undefined
revalidate ?? process.env.NODE_ENV === "production" ? false : undefined;
// 60 -> false (cache forever) · unset -> false in production · 0 -> undefinedSo a price or photo changed in the CMS could stay invisible on the live site for hours, until something revalidated the page. The fix passes 0. Like 60, it survives ??, but it's falsy, so the ternary yields undefined. Each fetch then inherits the page's own ISR window, and pages still cache. A test copies the library's expression and fails if anyone puts a number of seconds back.
In October, product pages and the home page moved to "or throw" readers, which throw an error when a CMS read fails instead of returning empty data or a default. A failed read then fails the background rebuild, and the last good copy stays up. notFound() runs only after a successful read, so an outage can't become a cached 404.
The cart and checkout keep a forgiving reader for the store settings on purpose, because someone there is paying. The feed, built on demand, refuses to go out empty. A cache should fail by serving yesterday's truth, never today's empty page.
The money path
Prices come from the server
The September checkout hardening started from one rule: the browser chooses, the server prices. Every server action is a public endpoint in Next.js, so I treat it as one.
Money is integer paise. In floating point, 3 × 10400.4 is 31201.199999999997; in paise, 3 × 1040040 is 3120120, exactly ₹31,201.20. The server prices the cart once, as a quote, and carries it to payment. When the payment is captured, the amount has to match the quote and the gateway's order to the paisa, or a person reviews it.
Coupons are checked when applied, when the cart changes and at payment, and a capped coupon's limit is checked again at fulfilment, so "first N customers" stays N. A mixed adaptor pack is only as available as its scarcest part: if it needs four of one module and six are left, the store sells one pack.

Emails that only looked sent
The hardening also caught order confirmations that had stopped going out while the logs said they'd been sent. The email SDK doesn't reject when a send fails; it resolves with an error. Now every send checks for one:
const { data, error } = await resend.emails.send(message);
if (error) throw new Error(`Email not sent: ${error.message}`); // resolves, never rejects
return data;A failed send is written to the order's activity log, and mail goes out after the response with Next.js after(), so the store answers the gateway's webhook quickly. If an SDK can fail without throwing, check what it returns on every call.
Paid, with no order
In mid-October a customer paid by UPI and no order or confirmation email was created. The gap was in my design: only the shopper's browser could create the order after the payment window closed, and the backup path wasn't doing its job. I designed, reviewed, tested and shipped the fix the next day:
- The server saves a private snapshot of the checkout before the payment window opens: no snapshot, no window.
- One fulfilment function runs from three triggers: the browser callback, the gateway's webhook and a reconcile job every 10 minutes. It isn't a server action, so the browser can't call it directly.
- The database insert is the lock. A duplicate means another path got there first, and the caller finishes what that order still needs.
- Each later step (stock, coupon count, cart, emails) is marked done on the order. Stock and the coupon count are marked in the same write that changes them. A run that dies halfway is resumed, and a lease stops two runs resuming the same order at once.
- Authorised is not paid: the page says it's still confirming. Webhooks only fill in what's missing and never change an existing order's status.
- A payment that's held for review, still confirming or only authorised gets "please do not pay again".
Tests replay the incident: with the shopper gone, the webhook alone creates the full order, cuts stock once and sends the emails, and the reconcile job completes it when the webhook is down too. I'm proud of this part, and it exists because I got the first version wrong. The thank-you page is not your order system.
The admin the team runs on
If the team has to message an engineer to change a price or print a label, the store is missing a screen. I built a first admin in July and rebuilt it in September: 18 working screens, about 27,400 lines.

The ₹69,951 on the Products screen is product revenue, a different measure from the ₹70,650 on the Orders screen. The admin also has:
- An editor warning while a Studio draft is pending: "If that draft is published later, it will overwrite these edits".
- Thermal 4×6-inch labels with Code 128 barcodes and QR codes, and packing slips with no item prices.
- Errors returned as data from server actions, because Next.js hides thrown messages in production.
- A "Refresh storefront" button that pushes Studio edits past the page cache and logs who pressed it.
Edge cases the store already handles
Most of a store's code exists for cases a demo never hits. Write each as a sentence that starts with "when" and ends with what the store does. If you can't finish one, you've found a bug.
| When… | The store… |
|---|---|
| A signed-in buyer's session expires mid-checkout | Stops before the payment window opens: "Your sign-in session has expired. Please refresh the page and try again." |
| Someone edits prices in the cart before paying | Ignores them: the server prices every line from the catalogue |
| A coupon stops qualifying after the cart changes | Removes it with a message, and checks it again at payment |
| The last unit sells while another buyer is paying | Checks stock at Pay and again at capture, and holds the late payment for a person |
| The buyer pays in their UPI app and never returns to the tab | Creates the order and sends the emails from the webhook or the reconcile job |
| A colour sells out | Lists it as out of stock on the page and in the feed while the other colours sell |
| The restocking switch is on | Refuses new orders before any payment opens, and the feed marks everything out of stock |
| A price edit from the list lands while an order moves stock | Saves it: conflicts are checked per field |
| An order arrives at 12:30 am IST | Counts it on that IST day, though in UTC it's still the day before |
| An order is cancelled | Drops it from revenue and never puts stock back on its own |
| An order is deleted in admin | Offers to put its stock back only if the store can prove that order took stock |
| An exported cell starts with "=" | Prefixes an apostrophe so the spreadsheet shows it instead of running it |
Tests that can't reach production
The suite imports the real checkout, payment, email and admin modules, so one stray credential could let a test write to the live CMS, email a real customer or call the payment provider. Since it started on 18 September it has run behind a fence: every credential is fake, fetch throws "network disabled in tests", and an in-memory fake of the CMS throws on any query it doesn't know and records every write. The fence has tests of its own.
All 1,043 passed on 9 November, in 2.64 s: 538 cover the money path and 470 cover search, the Shopping feed and copy rules. The 18 adversarial payment tests are each named after the defect they reproduce, such as a single-use coupon redeemed by two parallel checkouts. Each one failed before its fix and passes now. There's no CI yet, so the tests run when I run them.
Not launched yet: where the load goes
Before you send traffic to a store, list every request the cache can't answer. That list is your load-test plan.
A few curl requests from India on 9 November got HTML from Vercel's Mumbai edge in 0.11 to 0.27 s. That's time to first byte, not a page-speed score. A STALE response, the old copy served while the page rebuilds in the background, came back as fast as a fresh HIT. Browsing comes from the CDN cache: product pages are prerendered, and 15 page types refresh in the background at most about once a minute.
The cache can't answer the cart, search, account pages, checkout, payment completion or email, or the services behind them: CMS writes, the payment gateway, the email provider and sign-in.
Capacity isn't measured yet, so I won't guess at a number. Next comes a load test against those paths, then alerts, CI that runs the tests on every push and an llms.txt, before we pull the switch.
Rules for your own store
- Search your category word in the market you sell in before you pick keywords.
- Give each search one URL and a title that says it back.
- Open each landing page with its answer, and build FAQ markup from what it renders.
- Read the raw HTML a crawler gets after every framework upgrade.
- Decide status codes and redirects before anything streams.
- Compute every price the page, the markup and the feed show from one source.
- Serve the last good copy when a rebuild fails.
- Price on the server in integer paise; the browser only chooses.
- Treat every server action as a public endpoint.
- Save the checkout before money moves, and let every path that sees the payment finish the same order.
- Fence your tests off from the network.
The store is at powertrack.online (opens in a new tab), and its kitchen page (opens in a new tab) and price page (opens in a new tab) show these rules at work. The PowerTrack India project page covers the stack and the pack builder, and if you'd like a store like this built, here's how to hire me.
