Modern Frontend Architecture with Next.js: Scalable, Mobile-First, and Domain-Driven

by William Warne, Co-founder & Lead Engineer

Introduction

Modern frontend applications don’t just need to work—they need to scale, hydrate instantly, support mobile-first interaction, and be offline-ready. We faced this challenge head-on building a Next.js App Router–based frontend that supports Supabase auth, PWA functionality, and server-rendering performance without compromising code clarity or maintainability.

Our Architectural Goals

We set four non-negotiable objectives:

  • Mobile-first user experience
  • SSR-first rendering with fast hydration
  • Simple, React-native state management (no Redux)
  • Domain-driven folder and logic organization

Our Stack Constraints

Our architecture is designed for:

  • Next.js 14+ (App Router)
  • Supabase (auth)
  • Prisma with DataProxy
  • PWA compatibility (offline support, push notifications)

Principle 1: Feature-First Folder Structure

We organize by domain, not file type. This keeps logic, UI, and API boundaries close and contextual:

/app/activities/
  page.jsx
  ActivityList.jsx
  useActivities.js
  activityService.js
  activityTypes.js

Why? To enable fast onboarding, domain encapsulation, and modular scaling.

Principle 2: Server + Client Rendering Harmony

We fetch data in Server Components (page.jsx) and pass it as props to Client Components. When needed, we hydrate with React Query:

const { data = initialActivities } = useActivities({ initialData });

This minimizes duplicate fetches while preserving interactivity.

SSR Tradeoffs and Best Practices

Fetching on the server delays first byte until the data is ready, which can improve the first meaningful paint but increase TTFB (Time to First Byte). We approach this pragmatically:

  • Critical page data (e.g., activity lists, user session) → SSR fetch in Server Component.
  • Non-critical/optional data → defer to client-side fetch in a Client Component with loading states.
  • Streaming/partial loading → where necessary, split with Suspense boundaries or loading.js to progressively render parts of the page.

How Hydration Fits In

After SSR, the Client Component hydrates the UI, making it interactive. React Query consumes the initialData and populates the client cache, preventing unnecessary refetches during hydration.

Real-Time Updates

Real-time updates (e.g., activity changes) are handled via:

  • React Query refetch() or background polling.
  • Supabase Realtime subscriptions.
  • Mutations that auto-invalidate queries and refetch updated data.

Loading States

Loading states are shown only during client-side navigation or when optional data is fetched asynchronously:

if (isLoading) return <LoadingSpinner />;
return <ActivityList data={data} />;

On first SSR load, loading states are skipped because data is already available.

Principle 3: Mutations That Sync Automatically

We use TanStack Query for all mutations. On success, it invalidates relevant queries:

useMutation(createActivity, {
  onSuccess: () => queryClient.invalidateQueries(['activities'])
});

This eliminates manual state updates post-API call. Less boilerplate, fewer bugs.

Principle 4: Application Hooks for Domain Logic

All business logic lives in feature-specific hooks like useCreateActivity(). These serve as our frontend's application layer—handling:

  • API calls
  • Cache invalidation
  • Derived state

Components stay declarative. Hooks handle the rest.

Principle 5: State Locality + Derivation

We only store what can't be derived. For example:

const total = useMemo(() => items.reduce(...), [items]);

No duplicated state. No accidental desyncs.

Minimal Global Context

We use React Context only for truly global concerns: auth session, selected user account. Everything else stays close to the component.

Supporting PWA Features

We register a service worker manually or use next-pwa. Push subscriptions persist via Supabase and are modeled with a PushSubscription Prisma model.

What We Avoided (and Why)

  • Redux, Zustand, MobX: overkill for our server-query-driven state
  • Component-type folders (components/, hooks/): leads to scattered logic
  • Global context for domain state: creates coupling and re-render chains

Final Thoughts

By designing with constraints in mind—mobile-first UX, SSR performance, domain separation—we built a frontend that’s fast, maintainable, and future-proof. React Query did the heavy lifting, application hooks isolated complexity, and our folder structure kept everything tied to real-world features.

This isn't just about clean code. It's about fast teams, less rework, and products that scale with confidence.

More Blog Posts

MVPs That Matter - Reframing the Launch Timeline

A strategic deep dive into how Zebra Labs reframes MVP planning to focus on user feedback, revenue generation, and sustainable product development. This is essential reading for non-technical founders navigating their first product build

Read more

Challenges building Saas businesses

Navigating the landscape of building a SaaS business is a multifaceted journey filled with challenges such as product-market fit, funding, scalability, and competition. Overcoming these obstacles requires strategic planning, adaptability, and a relentless focus on delivering value to customers in an ever-evolving market.

Read more

Tell us about your project

We're all ears.