Architecting with Constraints: A Pragmatic Guide

Don't just reach for your favorite framework. Let's explore how a constraint-based mindset can help you choose the right tool for the job and avoid over-engineering.


Architecting with Constraints: A Pragmatic Guide

You’re starting a new frontend project. What’s the first thing you do? For many of us, the instinct is to reach for a familiar, powerful, all-in-one framework like Next.js or stand-alone React. They’re popular, we know them well, and they can handle anything we throw at them.

But is that always the right first move? Often, we choose our tools before we’ve fully analyzed the problem. We treat every project like it’s destined to become a complex, highly interactive web application, even when the immediate requirements are for something much simpler.

This “framework-first” approach can lead to bloated, over-engineered solutions. A mostly static page shouldn’t need to ship half a megabyte of JavaScript to power a single dropdown menu. Let’s explore a different approach: starting with constraints and choosing our tools accordingly.

The Problem: The Default SPA Hammer

Imagine we’re building a simple product page. It’s mostly static content, but it has one interactive “Image Carousel” component.

The default path for many teams would be to spin up a new React or Next.js application. The package.json might immediately include:

{
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "next": "15.3.4"
  }
}

We’d build our carousel as a React component, wrap it in a Next.js page, and deploy it. This works, but at what cost? The user now has to download a significant JavaScript bundle just to view a simple page and interact with a single element. We’ve chosen a powerful, but heavy, tool for a small job. This is treating everything like a nail because we prefer a specific hammer.

A Better Way: Constraint-Based Architecture

What if we started by analyzing the constraints?

  1. The page is mostly static. It’s a perfect candidate for a static site generator (SSG) or a server-templating approach.
  2. Interactivity is isolated. Only the image carousel needs client-side JavaScript.

This points towards a different architecture. Instead of a client-side SPA, we can use a framework like Astro (or server templates) that excels at shipping zero JavaScript by default.

Here’s our Astro page. It looks like plain HTML with a little extra power.

---
// src/pages/product.astro
import Carousel from '../components/Carousel.wc.js';
---
<html lang="en">
<head>
  <title>Our Awesome Product</title>
</head>
<body>
  <h1>The Product Page</h1>
  <p>Here is some great marketing copy.</p>

  <!-- This is the only interactive part of our page -->
  <image-carousel client:load></image-carousel>

  <p>More static content down here.</p>
</body>
</html>

The magic is client:load. This tells Astro to treat <image-carousel> as an “island of interactivity.” Astro will render the page to static HTML on the server, and only ship the JavaScript needed for the carousel component itself.

Building the Interactive Island

But what is <image-carousel>? It’s a standard, framework-agnostic Web Component. We can build it with a library like Lit, which is lightweight and produces components that work anywhere.

// src/components/Carousel.wc.js
import { LitElement, html, css } from 'lit';

class ImageCarousel extends LitElement {
  static styles = css`/* ... our styles ... */`;

  // Internal state for the component
  _images = ['img1.jpg', 'img2.jpg', 'img3.jpg'];
  _currentIndex = 0;

  _next() {
    this._currentIndex = (this._currentIndex + 1) % this._images.length;
    this.requestUpdate(); // Re-render the component
  }

  _previous() {
    this._currentIndex = (this._currentIndex - 1 + this._images.length) % this._images.length;
    this.requestUpdate();
  }

  render() {
    return html`
      <div>
        <img src=${this._images[this._currentIndex]} alt="Product Image">
        <button @click=${this._previous}>Previous</button>
        <button @click=${this._next}>Next</button>
      </div>
    `;
  }
}

customElements.define('image-carousel', ImageCarousel);

This component is self-contained, simple, and only gets loaded on the client where it’s needed. We’ve satisfied the requirements of the design without forcing the user to download an entire SPA framework.

The Over-Engineered Approach: A Next.js 15 Example

Now, let’s build the same carousel in a Next.js 15 application. While Next.js is an incredibly powerful framework, using it for this simple component introduces more complexity and boilerplate than is necessary.

First, we need to separate our logic into Server and Client Components. The page itself will be a Server Component, but the interactive carousel must be a Client Component because it uses state and event listeners.

Here’s the page component:

// app/page.tsx
import { Carousel } from './carousel';

// This is a React Server Component (the default in Next.js 15)
export default function Page() {
  return (
    <main>
      <h1>The Product Page</h1>
      <p>Here is some great marketing copy.</p>

      {/* We render the Client Component here */}
      <Carousel />

      <p>More static content down here.</p>
    </main>
  );
}

Next, we create the carousel as a Client Component. This requires the "use client" directive at the top of the file. This tells Next.js to ship this component’s JavaScript to the browser.

// app/carousel.tsx
'use client';

import { useState } from 'react';

const images = ['/img1.jpg', '/img2.jpg', '/img3.jpg'];

export function Carousel() {
  const [currentIndex, setCurrentIndex] = useState(0);

  const previous = () => {
    setCurrentIndex((prevIndex) =>
      prevIndex === 0 ? images.length - 1 : prevIndex - 1
    );
  };

  const next = () => {
    setCurrentIndex((prevIndex) =>
      prevIndex === images.length - 1 ? 0 : prevIndex + 1
    );
  };

  return (
    <div>
      <img src={images[currentIndex]} alt="Product Image" />
      <button onClick={previous}>Previous</button>
      <button onClick={next}>Next</button>
    </div>
  );
}

While the React code itself is straightforward, we’ve introduced a few layers of complexity compared to the Astro and Lit example:

  1. Server/Client Boundary: We have to consciously manage what runs on the server versus the client, using the "use client" directive.
  2. More Boilerplate: We now have two files (page.tsx and carousel.tsx) to manage for a single feature.
  3. Larger JS Payload: Although Next.js is highly optimized, the baseline JavaScript required to hydrate the React client component is larger than that of the lightweight Lit component.

For a simple interactive island on a mostly static page, this approach is over-engineered. The tool, as powerful as it is, doesn’t match the constraints of the problem.

The Lightweight Alternative: SvelteKit

Perhaps you’re thinking, “Well, NextJS is known to be heavy. What about a more lightweight meta-framework?” SvelteKit is often praised for its efficiency and smaller bundle sizes compared to React-based solutions. Let’s see how the same carousel would look in SvelteKit.

The page structure is quite clean:

<!-- src/routes/+page.svelte -->
<script>
  import Carousel from '$lib/Carousel.svelte';
</script>

<svelte:head>
  <title>Product Page - SvelteKit Demo</title>
</svelte:head>

<main class="container">
  <h1>The Product Page</h1>
  <p class="intro">Here is some great marketing copy about our amazing product. This content is rendered on the server and delivered as static HTML.</p>

  <!-- This is the only interactive part of our page -->
  <Carousel />

  <div class="content">
    <h2>Product Details</h2>
    <p>More static content down here. This demonstrates how most of the page is static while only the carousel needs client-side JavaScript.</p>
    <ul>
      <li>Feature 1: Lorem ipsum dolor sit amet</li>
      <li>Feature 2: Consectetur adipiscing elit</li>
      <li>Feature 3: Sed do eiusmod tempor incididunt</li>
      <li>Feature 4: Ut labore et dolore magna aliqua</li>
    </ul>
  </div>

  <div class="highlight-box">
    <h3>Why Choose Our Product?</h3>
    <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
  </div>
</main>

And the carousel component uses Svelte’s reactive syntax:

<!-- src/lib/Carousel.svelte -->
<script>
  const images = [
    { src: '/demo-image-1.jpg', alt: 'Product Image 1' },
    { src: '/demo-image-2.jpg', alt: 'Product Image 2' },
    { src: '/demo-image-3.jpg', alt: 'Product Image 3' },
  ];

  let currentIndex = 0;

  function previous() {
    currentIndex = currentIndex === 0 ? images.length - 1 : currentIndex - 1;
  }

  function next() {
    currentIndex = currentIndex === images.length - 1 ? 0 : currentIndex + 1;
  }

  function goToImage(index) {
    currentIndex = index;
  }
</script>

<div class="carousel">
  <div class="carousel-container">
    <div class="image-placeholder">
      {currentIndex + 1} / {images.length}
    </div>
    
    <div class="controls">
      <button class="nav-button" on:click={previous} aria-label="Previous image">
        <svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
        </svg>
      </button>
      
      <button class="nav-button" on:click={next} aria-label="Next image">
        <svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
        </svg>
      </button>
    </div>
  </div>
  
  <div class="indicators">
    {#each images as _, index}
      <button
        class="indicator"
        class:active={index === currentIndex}
        on:click={() => goToImage(index)}
        aria-label="Go to image {index + 1}"
      ></button>
    {/each}
  </div>
  
  <p class="current-info">
    Current image: {images[currentIndex].alt}
  </p>
</div>

SvelteKit’s approach is undeniably cleaner than NextJS. The reactive syntax is intuitive, there’s no need for "use client" directives, and the overall developer experience is excellent. The framework compiles away much of its runtime, resulting in smaller bundles than React-based solutions.

However, even this lightweight, efficient meta-framework still brings more overhead than our constraint-based approach requires for this simple use case.

Why Constraints Lead to Better Architecture

Let’s compare the approaches with real data from our demo applications. The “Default SPA” method is comfortable and uses familiar tools. But the “Constraint-Based” method delivers a superior user experience:

Gzipped Bundle Size Comparison

NextJS Application:

  • Total: 104 kB

SvelteKit Application:

  • Total: 34.2 kB

Astro + Lit Application:

  • Total: 7.3 kB

The results speak for themselves. The Astro + Lit approach ships approximately 93% less JavaScript than NextJS and 79% less than SvelteKit; that’s a massive difference for the same functionality. Even SvelteKit, which is known for its efficiency, still ships 4.7x more JavaScript than the constraint-based approach.

The Benefits

  1. User Experience: The Astro and Lit example provides a seamless, interactive experience without forcing the user to download unnecessary JavaScript.
  2. Performance: The Astro and Lit example avoids the overhead of full SPA frameworks, resulting in faster load times and lower bandwidth usage - especially important for users on slower connections or mobile devices.
  3. Complexity: The Astro and Lit example avoids the complexity of managing server and client components, reducing the potential for bugs and maintenance headaches.

This isn’t to say that powerful frameworks are inherently bad. On the contrary, if our project’s constraints demanded high levels of interactivity across the entire application (like a project management dashboard, a spreadsheet app, or a real-time analytics platform) then starting with a full-featured framework would be the correct, constraint-based choice. In that scenario, a framework like SvelteKit would be an excellent option, as it provides the necessary power while maintaining a simpler design and staying closer to web standards than more complex alternatives.

The Lesson: Engineer, Don’t Just Implement

By comparing these approaches, we see that the constraint-based method using Astro and Lit delivers a superior user experience for this specific problem. It’s faster, simpler, and more flexible. The trade-off, of course, is that if this page were to rapidly evolve into a full-blown application, we might need to use more and more framework code as interactive islands in our Astro app.

This is the essence of good architecture: making deliberate choices based on trade-offs. It’s about understanding that developer comfort should not be the primary driver of technical decisions. Performance, user experience, and project requirements should come first.

By starting with constraints, you force yourself to think critically about what the application truly needs. You move from simply implementing features in your favorite tool to engineering a solution that is purpose-built for the problem at hand. This mindset is what separates developers from architects.