Building Effective Client-Side Web Applications: A Simple Guide


As a developer who loves the balance between performance and developer experience, I often choose Next.js for building client-side web applications. While Next.js is known for its SSR (Server-Side Rendering) capabilities, I use it in a slightly different way for client side apps — by statically exporting the app using output: 'export'
in the config.
This blog walks you through how and why I use this approach — and how you can too.
Why Next.js with Static Export?
While tools like Vite or Create React App (CRA) are commonly used for building client-side React apps, they rely on a traditional SPA architecture. This means:
They inject all the JavaScript into a single root
<div>
inindex.html
The entire UI is rendered on the client side
The browser has to download, parse, and execute a potentially large JavaScript bundle before anything meaningful appears on screen
This can lead to:
Slower first paint and time-to-interactive
Performance bottlenecks in larger apps
Bloated JS bundles if not optimized carefully
On the other hand, by using Next.js with output: 'export'
, I get:
Pre-rendered static HTML for every page at build time
Faster load times, as the browser doesn't have to wait for JS to render basic content
All the DX benefits of Next.js (routing, file-based structure, CSS modules, etc.)
So I still build a fully client-side app — but with a structure that’s faster to load, easier to maintain, and ready to deploy anywhere.
Core Principles I Follow
1. Keep Most Components Server-Side (or Static)
Even though I’m building a “client-side” app, I aim to make as many components as possible static or server-rendered at build time. This means:
Use pure React components without
useEffect
,useState
, or browser-only logic where not neededPre-render pages and content where possible
Static components = faster load, less JavaScript
2. Server Components as Children of Client Components
One powerful and often underused feature in Next.js is this:
If you pass a Server Component as a child to a Client Component, it remains a Server Component.
I use this pattern a lot. For example:
<ClientWrapper>
<ServerOnlyComponent />
</ClientWrapper>
This allows me to keep large or static content server-rendered, while wrapping it in a small interactive layer on the client — best of both worlds.
3. Minimal JavaScript on the Client
Only include client-side logic when absolutely necessary:
User interactions (forms, toggles, tabs)
Dynamic data fetching
Everything else stays server-side to avoid bloated JS bundles and long hydration times.
4. Break UI Into Reusable Components
I think this goes without saying. I design all pages using small, modular components:
Shared layout primitives (Container, Section, Card)
Page-specific blocks (Hero, FeatureList, Testimonial)
Theme-friendly and responsive by default
This makes scaling and styling far easier as the app grows.
5. Think Static First, Dynamic Later
Before reaching for a client-side hook or fetch, I ask:
“Can I pre-render this during build?”
If yes, I do it. This mindset helps me avoid unnecessary client code, and improves load times and reliability.
Demo Project
Project Structure
Project Architecture
The
HomePage
is wrapped with a client component calledHomePageProvider
.Inside this provider, I use TanStack Query to fetch data from an API.
The fetched result — including
data
,isLoading
, anderror
— is passed into a React Context namedHomePageContext
."use client"; export const HomePageProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const { data, error, isLoading } = useQuery<Beer[]>({ queryKey: ["beers"], queryFn: () => fetch("https://api.sampleapis.com/beers/ale").then((res) => res.json().then((data) => data.slice(0, 20)) ), }); return ( <HomePageContext value={{ data, error, isLoading }}> {children} </HomePageContext> ); };
This way:
Most of the page remains pure static JSX — lightweight and SEO-friendly.
Whenever I need to show dynamic data (like user info, stats, or a chart), I create small client components that:
Use
useHomePageData()
hookRead just the data they need (and show loading/error states if required)
"use client" export const BeerTableRows = () => { const { data, error, isLoading } = useHomePageData(); return ( <> {data?.map((beer) => ( <tr key={beer.id}> <td className="px-5">{beer.name}</td> </tr> ))} </> ); };
Why this works well
Separation of concerns: Fetching logic lives in a single place (the provider).
Reusable context: Any nested component can access the fetched data without prop drilling.
Minimal hydration: Only the parts that really need to be interactive are client components.
Fast static shell: The rest of the page loads instantly from the exported HTML.
Build and Deployment
To build the project for static export, I use the following setting in next.config.js
:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;
When I run: npm run build
Next.js will generate an out/
directory that contains:
A separate
index.html
file for each routeStatic assets like images, CSS, and JS
Pre-rendered pages with embedded data wherever possible
Since it's now just plain HTML, CSS, and JS, I can deploy this to any object store like:
Amazon S3 — as static files
CloudFront — for global edge caching and fast delivery
Or any static host (Netlify, GitHub Pages, Firebase Hosting, etc.)
Final Thought
This setup gives me the best of both worlds: a fast, SEO-friendly, fully static site with the flexibility to selectively add dynamic functionality — all while keeping the client-side JavaScript footprint minimal.
Subscribe to my newsletter
Read articles from Harshit Bansal directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
