There's a particular kind of clarity that only shows up once you stop reading about a technology and start building something with it. You can read ten articles about asynchronous JavaScript and nod along at every one of them. It's only when your database fills up with several thousand empty documents because of a missing curly brace that the concept actually clicks.
This is the story of Skimp, a small web application for sharing personal discount codes, and the full-stack JavaScript project behind it. It's also, less glamorously, a story about JS engines, callback hell, and why "just use Redux" is easy advice to give and harder advice to follow under a deadline.
Why JavaScript Ate the Web
JavaScript's rise to dominance is one of those slow-burn stories that only looks inevitable in hindsight. It started in 1995 as a scripting language for small tasks inside the Netscape Navigator browser, things like form validation and simple animations, nothing that needed real performance. Back then, a dynamically typed, interpreted language was a perfectly reasonable choice for that scope. It was never meant to run the backend of anything.
Two developments changed that trajectory. The first was the arrival of Ajax in 2005, which let web pages exchange small chunks of data with a server without reloading the entire page. It became the technical foundation of every "modern-feeling" website since. The second was the release of Node.js in 2009, which took the V8 engine that powers Chrome and let it run outside the browser entirely. For the first time, a single language could plausibly cover both sides of a web application.
That second point matters more than it sounds. Modern JavaScript engines like V8 don't simply interpret code line by line; they combine an interpreter with a Just-in-Time (JIT) compiler. Code first runs through an interpreter (in V8's case, one called Ignition), while the engine collects profiling data about which parts of the code run most often. Frequently executed sections then get handed off to an optimizing compiler (Turbofan) and turned into fast machine code. This hybrid approach is what allows a dynamically typed language, one that has to check a variable's type at runtime instead of compile time, to remain competitive with statically typed alternatives. Engines further speed this up using techniques like "Shapes" and "Inline Caches," which let the engine remember where an object's properties live in memory instead of looking them up every single time. It's a genuinely elegant piece of engineering hiding behind a language many people still associate with dropdown menus and pop-up ads.
The other pillar of modern JavaScript is asynchronous programming: the ability to start a task, move on to something else, and come back to the result later, instead of blocking execution while waiting. Early JavaScript handled this through callbacks, functions passed as arguments to other functions, to be invoked once some operation completes. It works, but nested callbacks get unreadable fast, a phenomenon developers not-so-fondly call "callback hell." Promises, introduced later, wrap this pattern in an object with a clear lifecycle: pending, then either fulfilled or rejected. Combined with the async/await keywords, asynchronous code can now be written almost as if it were synchronous, which is a genuine readability win, and one that mattered a great deal in the project described below.
Full-Stack, Meet MERN
"Full-stack Development" is a term that gets used loosely, but the underlying idea is simple: one person or team owns both the client-facing part of an application and the server logic behind it, ideally in a way where the two halves communicate cleanly over the internet. JavaScript is uniquely positioned for this because, thanks to Node.js, it's one of the few languages that can genuinely run the same syntax, and even share code, on both sides of that boundary.
For the client side, the project settled on React. Facebook's library isn't the only option, Vue and Angular both have loyal followings, and each makes different trade-offs between flexibility, structure, and learning curve, but React's documentation, its enormous ecosystem, and the sheer volume of tutorials available made it the pragmatic choice for a team learning most of this from scratch.
On the server side, Node.js paired with the Express framework provided the HTTP layer, routing, and middleware handling. Express's architecture is refreshingly minimal: a base HTTP module handles the raw request/response cycle, a middleware layer sits in between to run logic on incoming requests, and a router decides which function handles which combination of URL and HTTP method (GET, POST, PUT, DELETE). It's a small enough mental model that you can hold the whole thing in your head, which matters when you're debugging at 11pm.
For data storage, the project used MongoDB, specifically a free-tier cluster on MongoDB Atlas. MongoDB's appeal in a JavaScript stack isn't subtle: it's a document database that stores data as BSON, a binary variant of JSON, so the data format barely changes as it moves between database, server, and browser. No translation layer between "relational rows" and "JavaScript objects" is needed. A library called Mongoose sits on top of it to define schemas, giving the otherwise-flexible database a bit of structure, like marking fields as required or setting default values.
Put together (MongoDB, Express, React, Node), this is the MERN stack, one of several standard combinations (MEAN and PERN being the more notable alternatives, swapping Angular for React or MongoDB for PostgreSQL respectively).
The Idea: Skimp
Every project needs a problem worth solving, however small. The one chosen here: coupon codes that arrive bundled with online orders, things like free shipping on your next purchase or a discount on gas, often go unused simply because the recipient has no reason to use them. Existing tools like the browser extension Honey, or category sites like loveminty.de, help you find general discount codes anyone can use. None of them, as far as the team's research turned up, let people share codes that were issued personally to them. That gap became Skimp ("to skimp, to economize"), a small portal for passing along coupons you'll never redeem yourself.
The requirements were deliberately scoped for a semester project rather than a production system. Must-haves included: showing all available coupons on load, letting users submit their own, validating form input before it reaches the database, a responsive layout that works without pinch-zooming on a phone, a working redeem-and-copy flow, and automatic removal of already-used codes. Nice-to-haves, some of which made it in and some of which didn't, included dark mode, expiry-based auto-deletion, and progressive loading for larger datasets.
From Concept to Code
The frontend was broken into independent React components: a CouponList, individual Coupon cards nested inside it, a submission form, and shared header/footer elements. This is where React's component model earns its reputation: state and behavior stay local to a component, but data can be "lifted up" to a shared parent (in this case the top-level App component) whenever multiple components need access to the same information, such as the current list of coupons.
The form itself is a good showcase of a subtlety that trips up a lot of React beginners: controlled components. In a plain HTML form, an input field just holds its own value internally. In React, it's generally safer to make the input's displayed value depend entirely on a piece of state, with every keystroke updating that state via an event listener. It's marginally more code to write, but it eliminates an entire category of bugs where the UI and the underlying data silently drift apart. Combined with the react-hook-form library for validation, the form checks required fields (shop name, code, value) before it ever reaches the network layer.
Submitting a coupon triggers a POST request via Axios, a promise-based HTTP client, to an /add route on the Express server, where the JSON payload is parsed and written to MongoDB as a new Mongoose document. Reading the current list back out uses a useEffect hook that fires a GET request to /read as soon as the component mounts, storing the result in state and rendering one Coupon component per array entry via .map().
Redeeming a coupon reveals the hidden code and a "copy to clipboard" button; behind the scenes, a cashCoupon function checks whether the coupon is reusable. If not, a DELETE request, carrying the coupon's MongoDB-assigned ID as a URL parameter, removes it from the database in the background, so the next visitor never sees it again. Reusable coupons instead get their internal useCount incremented via a PUT request. The search feature, meanwhile, is entirely client-side: a controlled input field filters the already-loaded array using a case-insensitive includes() check, simple, but for a dataset this size, more than adequate.
Deployment split the application across three separate services, each doing one job: MongoDB Atlas for the database, Heroku for the Node/Express server, and Netlify for the built React client. None of these steps were individually hard, but they were a good lesson in how many small, easy-to-forget details, environment variables, CORS configuration, swapping localhost URLs for production endpoints, separate "it runs on my machine" from "it runs on the internet."
What Actually Went Wrong
It would be a more boring article if everything had gone smoothly, and it wouldn't be an honest one. A few things are worth calling out specifically, because they're the kind of mistakes that teach you more than the parts that worked on the first try.
State management got messy. With state passed down as props through multiple nested components, the codebase started to feel harder to reason about as it grew. In hindsight, a global state management tool like Redux would have kept things cleaner, but learning an entirely new library on top of everything else already being learned simultaneously wasn't a trade-off the timeline allowed for. This is a genuinely common tension in real projects: the "correct" architectural choice and the choice you can actually execute on time aren't always the same one.
Dates are always harder than they look. Storing an expiry date as a raw string seemed simple until checking for "already expired" coupons became unreliable. Storing it properly as a Date object solved the logic problem but introduced a new one: MongoDB stores dates in ISO 8601 format, which is precise but not remotely human-readable. The eventual fix, the moment.js library formatting the ISO string into a normal European date on the client side right before rendering, is a small detail, but it's a good example of how a "trivial" feature can eat disproportionate time.
Node's error messages weren't always helpful. Stack traces frequently pointed into the node_modules folder rather than the team's own code, or referenced line numbers in index.js that simply didn't exist for a file that short. Attempts to split routes and database logic into separate files, generally considered better practice for larger applications, didn't fully succeed within the project's timeframe, and the team fell back to a single, longer file that at least worked reliably.
And then there was the infinite loop. A single forgotten closing bracket in the frontend caused addToList to fire repeatedly and uncontrollably on startup. Because form validation hadn't been implemented yet at that point, several thousand empty coupon documents flooded the database within seconds. The fix wasn't elegant, it was deleting the entire MongoDB cluster and starting a fresh one, but sometimes the fastest solution really is the right one.
Did It Actually Work?
Evaluated against the original requirements, yes: every single must-have requirement was met. The coupon list loads and displays correctly, submission and validation both work, the layout is responsive via CSS media queries, redeeming and copying codes function as designed, used single-use coupons disappear from the list, and navigation is effectively instant thanks to a single-page layout with no full reloads.
Several of the optional requirements also made it into the prototype: clipboard copying, automatic dark mode (detected via a CSS feature query rather than a manual toggle, which ended up being a nice unplanned upgrade), and automatic deletion of expired coupons on each page load. What didn't make the cut, and would matter for anything beyond a semester prototype, includes user accounts, progressive loading of large coupon sets, and server-side search instead of the current client-side filtering, all reasonable next steps rather than fundamental flaws.
The Bigger Picture
Stepping back from Skimp specifically, the broader takeaway from this project is less about any single technology and more about what a unified language stack actually buys you. Writing both client and server in JavaScript didn't just save time on context-switching between syntaxes, it meant the same JSON-shaped data could travel from MongoDB, through Express, to the React frontend without ever needing translation. That coherence is, in the end, the real argument for full-stack JavaScript: not that any individual piece (React, Node, MongoDB) is unambiguously the "best" tool in its category, but that the combination reduces the number of seams where things tend to break.
None of this made the learning curve gentle. Async programming, a NoSQL data model, and a component-based UI framework were all genuinely new concepts, tackled more or less simultaneously. What made it tractable was the sheer size of the ecosystem around these tools, official docs, tutorials, and a large enough community that most errors, once searched for, turned out to have already been solved by someone else. That's arguably JavaScript's biggest advantage at this point: not the language itself, but the depth of the collective knowledge built up around it.
