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.