What I Learned From Three Small Projects
In 2025 I focused on building small, focused projects to sharpen specific skills. Large applications often hide the root cause of a problem behind layers of abstraction. By isolating issues in smaller codebases, I could confront and fix the actual misunderstanding instead of patching around it. This approach is consistent with the Next.js App Router documentation, which recommends isolating features to reduce build and routing complexity.
This post walks through three of those projects: AnimalSounds, CheeseMath, and EthicsFrontEndDemo. For each one I will explain the real failure, what I changed, and why the lesson stuck.
Here is a quick comparison of the three projects and the core lesson from each:
| Project | Stack | Core failure | Resolution | Lesson |
|---|---|---|---|---|
| AnimalSounds | Next.js, HTML5 Audio | iOS Safari AudioContext lock + GitHub Pages subpath 404s | Lazy audio init + basePath config | Static hosts need path config that differs from local dev |
| CheeseMath (Jest tests) | Jest, ES6+ JavaScript | SyntaxError: Cannot use import statement outside a module | Babel bridge (@babel/preset-env) targeting Node current | Test runners need a dedicated transpilation pipeline |
| EthicsFrontEndDemo | Vanilla CSS, CSS Grid | Flexbox layout thrashing on mobile viewport | Switched macro layout to CSS Grid with auto-fit + minmax | Pick the layout engine that matches the dimensionality of the problem |
Project 1: AnimalSounds
Repository: AnimalSounds
Live deployment: View demo
The challenge: browser autoplay policies and static subpaths
The initial scope was simple: a DOM manipulation exercise that triggers HTML5 <audio> elements via JavaScript event listeners (MDN: HTML5 audio element). I used a standard Next.js App Router structure (Next.js App Router docs).
Failure point 1: the iOS audio context lock
After deployment, the app worked on desktop Chrome but failed silently on iOS Safari. It tried to instantiate the AudioContext and preload assets on window.onload.
Mobile browsers enforce strict autoplay policies. (See Apple's WebKit autoplay policy documentation and Chrome's autoplay policy.) The AudioContext starts in a suspended state on WebKit browsers (MDN: AudioContext — suspended state). It cannot transition to running until a distinct user interaction event, usually touchend or click, occurs. My code was trying to play audio before that handshake happened, so the browser rejected the request.
Resolution
I refactored the audio engine to use a lazy-load pattern. I removed the automatic initialization and replaced it with a singleton that checks the context state. The first user interaction on the page now triggers a zero-volume buffer playback, which effectively unlocks the audio subsystem for the rest of the session. The steps I followed were:
- Remove the
window.onloadaudio initialization. - Create a singleton
AudioContextthat starts insuspendedstate. - Attach a one-time
touchend/clicklistener that callscontext.resume(). - Play a zero-volume buffer to unlock the audio subsystem.
- Set a flag so subsequent interactions skip the unlock step.
This pattern is documented in Google's Web Audio API best practices.
Failure point 2: GitHub Pages subpath routing
The second failure was asset resolution. Locally, at localhost:3000, an image reference to /images/cow.png resolves to localhost:3000/images/cow.png.
GitHub Pages hosts project repositories on a subpath: username.github.io/repo-name/ (GitHub Pages documentation). When deployed, the app kept requesting assets from the domain root, so every static media file 404ed.
Resolution
Hardcoding relative paths (./) proved brittle. The robust fix was Next.js environment configuration. I added a basePath in next.config.js that applies the repository name only during production builds (Next.js basePath documentation).
// next.config.mjsconst isProd = process.env.NODE_ENV === 'production';const repoName = 'AnimalSounds';const nextConfig = {basePath: isProd ? `/${repoName}` : '',assetPrefix: isProd ? `/${repoName}/` : '',output: 'export',images: {unoptimized: true, // Required for static export},};export default nextConfig;
This keeps asset routing logic identical across environments while the build pipeline rewrites paths dynamically.
Project 2: CheeseMath (Jest tests)
Repository: CheeseMath-Jest-Tests
Live deployment: View demo
The challenge: ESM vs. CommonJS in test environments
The goal was a robust unit testing suite using Jest (Jest documentation). The app logic involved geometric calculations written in modern ES6+ JavaScript.
Failure point: the “Cannot use import statement” error
The project used ES modules (import/export) to stay compatible with modern bundlers (MDN: JavaScript modules). But Jest runs in a Node.js environment, which historically defaults to CommonJS (require) (Node.js modules documentation).
Running the test suite crashed immediately with SyntaxError: Cannot use import statement outside a module. Node.js was executing the source files directly without transpilation, so it did not recognize import in .js files unless package.json specified "type": "module" or the file used .mjs.
Simply changing the package type broke other tooling. I discovered the testing environment and the production environment were effectively two runtimes with different build pipelines.
Resolution
I integrated Babel as a bridge between source code and the test runner (Babel documentation). That required installing babel-jest, @babel/core, and @babel/preset-env (Jest — using Babel).
I configured babel.config.js to target the current Node version used by Jest, rather than the browser targets used for the build.
// babel.config.jsmodule.exports = {presets: [['@babel/preset-env',{targets: {node: 'current', // Transforms ES6 import to CJS require for Jest},},],],};
This lets the source code stay clean, modern ES6 while Jest receives the CommonJS it expects at runtime. The lesson was that “testable code” often needs a dedicated compilation infrastructure.
Project 3: EthicsFrontEndDemo
Repository: EthicsFrontEndDemo
Live deployment: View demo
The challenge: CSS Grid vs. Flexbox for two-dimensional layouts
This project built a responsive corporate landing page without CSS frameworks. The main challenge was structuring the layout grid.
Failure point: the Flexbox trap
I initially laid out the entire page using Flexbox. Flexbox is great for one-dimensional alignment — distributing items in a single row or column (MDN: Flexbox) — but it struggles when elements must align on both the X and Y axes at the same time.
To force a grid with Flexbox, I had to use negative margins, complex calc() width percentages like width: calc(33.33% - 20px), and extra container wrappers to handle wrapping. The result was layout thrashing on mobile, where content overflowed the viewport horizontally because the padding calculations did not account for the scrollbar width.
Resolution: CSS Grid
I scrapped the Flexbox approach for the main container and implemented CSS Grid (MDN: CSS Grid Layout). The difference in code complexity was substantial. (See the actual CheeseMath repo for the real code.)
Flexbox vs. CSS Grid for two-dimensional layouts
| Criterion | Flexbox | CSS Grid |
|---|---|---|
| Dimensionality | One-dimensional (row or column) | Two-dimensional (rows and columns) |
| Alignment control | Main axis + cross axis | Both axes simultaneously |
| Wrapping behavior | flex-wrap requires manual margin/calc() hacks | auto-fit + minmax handles wrapping natively |
| Media queries needed | Multiple breakpoints for percentage resizing | Often none — browser distributes space |
| Best for | Nav bars, toolbars, single-row components | Page-level macro layouts, card grids, dashboards |
The failed Flexbox approach:
.container {display: flex;flex-wrap: wrap;margin: -10px;}.card {flex: 0 0 calc(33.33% - 20px);margin: 10px;}/* Media queries required to manually resize percentages for tablet/mobile */
The successful Grid approach:
.container {display: grid;grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));gap: 20px;}
Using grid-template-columns with auto-fit and minmax lets the browser handle space distribution (MDN: grid-template-columns). It automatically decides how many columns fit and wraps to the next row when the minimum width is violated. That eliminated multiple media queries and manual margin calculations, proving Grid is the better engine for macro layouts.
What all three taught me
- AnimalSounds proved that static hosting environments need path configuration strategies (
basePath) that differ from local development. - CheeseMath proved that modern JavaScript needs a transpilation pipeline (Babel) to bridge the gap between ESM source code and CommonJS test runners.
- EthicsFrontEndDemo proved that choosing the right layout engine (Grid over Flexbox) reduces code volume and technical debt.
Technical growth comes from encountering and resolving these specific environment-level conflicts, not from reading documentation alone.
Closing
Small projects are not practice dummies. They are the fastest way to find the real boundary between “I read about this” and “I can actually make this work.” These three demos were tiny, but each one fixed a misunderstanding I would have carried into larger projects.
Keep reading
- Recent Projects and Lessons: A Short, Verifiable Format
- How I Learn by Doing: A Repeatable Mini Project Loop
- Designing Systems That Actually Ship: A Small Template
References
- Next.js App Router documentation
- Next.js basePath configuration
- MDN — HTML5 audio element
- MDN — AudioContext suspended state
- Apple WebKit autoplay policy documentation
- Chrome autoplay policy
- Google Web Audio API best practices
- GitHub Pages documentation
- Jest documentation — getting started
- Jest — using Babel
- Node.js modules documentation
- MDN — JavaScript modules
- Babel documentation
- MDN — Flexbox
- MDN — CSS Grid Layout
- MDN — grid-template-columns
- AnimalSounds repo
- CheeseMath-Jest-Tests repo
- EthicsFrontEndDemo repo
