Most first internship projects are: add a filter to this dashboard. Mine was: build a production platform for a hardware shop in Nehru Place. Live inventory. Real customers. Real transactions. Zero hand-holding.
The shop is Hitech Infovision. Next.js 16, TypeScript, Supabase, Tailwind v4, Zod, Vercel. Live at hitechinfovision.com. This is the breakdown, mistakes included, plus a few things I wish someone had told me before I started.
Why Not Just Use Shopify?
Because the client asked for something that reflected the business. A Shopify store with a free theme says this shop couldn't be bothered. A custom platform says this shop is serious. That distinction matters when the competition is three floors of identical stalls selling the same hardware at the same price.
Anyway, the other reason is control. With Shopify, the client pays forever and can't touch anything structural. A custom build means they own it. That felt like the right call.
(I also just wanted to build something real. "I deployed a Shopify store" isn't a sentence that gets you anywhere interesting.)
Storefront: Fast by Default
The product catalogue is fully server-rendered. No loading spinners. No skeleton screens. No client-side fetches for data that already exists at render time.
// app/products/page.tsx
export default async function ProductsPage() {
const { data: products } = await supabase
.from('products')
.select('*')
.eq('is_active', true)
.order('category');
return <ProductGrid products={products ?? []} />;
}
The page component fetches from Supabase at render time on the server and streams
HTML directly to the browser. The user gets a fully rendered page on the first byte.
Performance here was an architecture decision, not a retrofit. I didn't profile
anything or sprinkle in useMemo calls after the fact. The structure is just fast.
The Security Stack
The access control is real authentication plus a server-side authorization check: the server confirms there is a valid session and that the session belongs to an admin before any privileged database client is handed back. I'm keeping the details of that layer out of this public write-up, since it guards a live client site. What's worth sharing is the defence-in-depth around it. Controls that limit the blast radius on the assumption that something, somewhere, will eventually go wrong.
Headers. Every response gets a set of security headers: X-Frame-Options: DENY to
block clickjacking, X-Content-Type-Options: nosniff, a strict Referrer-Policy so
external links only leak the origin rather than a full URL that might carry IDs, and a
Permissions-Policy that revokes camera, microphone and geolocation. The site has no use
for any of them, so any injected script that reaches for a hardware API gets nothing.
Service role key isolation. The Supabase service_role key, which bypasses Row Level Security, is kept server-only and never bundled into the client, and the admin client that uses it holds no session state. The browser only ever sees the constrained anon key.
Row Level Security. Public data (products, categories, services) is readable by the
anon role, and writes are denied to it entirely. Admin writes go through the server-side
path only after the authorization check passes. The application checks the session; the
database enforces access whether or not the application code is correct.
Input validation. Every write endpoint validates the full request body with Zod before touching the database. The schemas are not shallow. They include cross-field rules:
const baseProductSchema = z.object({
name: z.string().min(2).max(100),
slug: z.string().min(2).max(100).regex(/^[a-z0-9-]+$/),
price: z.int().positive().nullish(),
price_type: z.enum(['fixed', 'contact']),
images: z.array(z.string().url()).max(10).default([]),
availability: z.enum(['available', 'on_order', 'check']),
})
.superRefine((data, ctx) => {
if (data.price_type === 'fixed' && data.price == null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Price is required when price_type is 'fixed'",
path: ['price']
})
}
})
Only parsed.data reaches the database. The original request body is discarded.
The TypeScript type and the runtime validator are the same definition, so they
can't drift apart.
Search sanitization. The Supabase PostgREST API uses parameterized queries internally, so classical SQL injection isn't possible. But malformed inputs with special characters can still produce unexpected behaviour in PostgREST's filter syntax. The search input gets stripped of PostgREST delimiter characters, trimmed, and hard-capped at 200 characters before it touches a query. The length cap also prevents expensive full-table scans from an oversized input.
Pagination clamping. Every paginated endpoint clamps the limit parameter:
const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') ?? '20', 10) || 20))
The double-guard handles everything: non-numeric strings produce NaN, which the
|| 20 fallback catches. Math.max(1, ...) prevents zero or negative limits.
Math.min(50, ...) caps the upper bound. One line. No edge cases.
Audit logging. Every successful admin write is logged. Create, update, delete. All of them. The log records the admin's user ID, the action type, the affected entity, which fields changed, and the request IP. The log is append-only. The database types make it a TypeScript compile error to update the audit table, and there is no function anywhere in the codebase that deletes log entries. On top of that, the RLS policy at the database level denies UPDATE and DELETE to everyone, including the service_role. Even a future developer who accidentally writes a delete operation gets refused by the database. Three layers: compile time, application code, database. The goal is that covering your tracks is structurally impossible.
Financial Data: An Integer Problem
Prices are stored as integers. 1 rupee = 100 paisa.
// All prices in paisa. Display layer handles formatting.
const displayPrice = (paisa: number) =>
`₹${(paisa / 100).toLocaleString('en-IN')}`;
No floating-point arithmetic in financial code. Ever. 0.1 + 0.2 === 0.30000000000000004
isn't a quirk you read on a blog and then forget. It shows up in a customer complaint
at 11pm and takes two hours to trace back to a price calculation that looked obviously
fine. Store integers. Divide at display time. This isn't optional.
(I learned this from reading incident reports, not from experiencing it myself. I would like to keep it that way.)
Design System: Rejecting the AI Aesthetic
Every tech product in 2026 looks the same. Rounded corners. Soft shadows. Inter. Indigo accent. The visual equivalent of "we installed a component library." I didn't want Hitech Infovision to look like a SaaS dashboard. I wanted it to look like a hardware shop. Dense, direct, industrial.
I set four rules before writing a line of CSS:
- No
border-radiusabove2px. - No drop shadows.
- No icon library. Typographic markers only.
- One accent color: Signal Red.
Typefaces are Archivo Black for headings and IBM Plex Mono for all data. The result looks like a Swiss industrial catalogue.
If your design looks like it came out of a component library, it probably did. And if it did, you haven't made a design decision. You've made a default. A shop trying to stand out in Nehru Place can't afford that.
Moments Where I Had No Idea What I Was Doing
The RLS policy that silently blocked everything. Row Level Security in Supabase
is enforced at the database level. I turned it on, wrote what I thought was a correct
policy, and the entire product catalogue returned zero rows. No error. Just nothing.
The policy was fine, tho. I'd just forgotten to
allow the anon role to run SELECT. Took two hours to see it. RLS doesn't fail loudly. It just locks the door.
The price that displayed wrong for three hours. I had a display function that
formatted prices correctly in isolation. In production, it was pulling from a column
I hadn't migrated yet, which defaulted to null, got coerced to 0, and displayed
as ₹0.00. Three hours, cause I kept staring at the function instead of the data.
The admin session that kept expiring mid-edit. Supabase JWTs expire. I hadn't
set up token refresh logic. The admin would start editing a product, spend five minutes
photographing it, come back, hit submit, and get a 401. I added onAuthStateChange
to the admin layout on day two. It should have been day one.
CSE student. Full stack engineering intern. I build things and then spend an embarrassing amount of time making sure they can't be broken.