How I structure React Next.js patterns across every client project
By Nayan Kyada · · 6 min read
Part of Next.js Performance
Every new Next.js project I start has the same skeleton. Not because I copy-paste a boilerplate, but because I've converged on a set of React Next.js patterns that solve the same class of problems every time — server-first rendering, predictable data flow, typed boundaries, and consistent error handling. This post documents those patterns so I can point clients and collaborators at something concrete.
Server components by default, client components by exception
The App Router made server components the default. I treat that as a firm rule, not a suggestion. Every file starts as a server component. It only gets the 'use client' directive if it needs browser APIs, event handlers, or client-side state.
The practical test I run before adding 'use client':
- Does it need
useStateoruseReducer? → client. - Does it need
useEffector a browser API (window,IntersectionObserver)? → client. - Does it need an event handler (
onClick,onChange)? → client. - None of the above? → keep it on the server.
This matters for bundle size. A server component ships zero JavaScript to the browser. Keeping data-heavy list pages, markdown renderers, and layout wrappers as server components keeps the client bundle lean.
When I do need interactivity, I push the 'use client' boundary as far down the tree as possible — often to a small leaf component like a <LikeButton> or <MobileMenuToggle>, not the whole page.
Colocated data fetching, not a central data layer
I fetch data inside the server component that renders it. No global store, no prop-drilling query results from a layout down to a leaf. Each component owns its data.
// app/blog/[slug]/page.tsx
import { client } from '@/sanity/lib/client'
import { postBySlugQuery } from '@/sanity/lib/queries'
import type { PostBySlugQueryResult } from '@/sanity/types'
interface Props {
params: Promise<{ slug: string }>
}
export default async function PostPage({ params }: Props) {
const { slug } = await params
const post: PostBySlugQueryResult | null = await client.fetch(
postBySlugQuery,
{ slug },
{ next: { tags: [`post:${slug}`] } },
)
if (!post) notFound()
return <PostBody post={post} />
}Next.js deduplicates fetch calls with the same URL and options within a single render pass, so two sibling components fetching the same resource don't double-hit the API. For Sanity specifically I use tagged revalidation so deploys stay fast.
If a page needs data from multiple sources — say a post plus a related-posts sidebar — I fetch in parallel with Promise.all at the page level and pass results down as props. I don't waterfall fetches through nested components.
Typed API boundaries with Sanity TypeGen
Loose any types at the data boundary kill refactor confidence. I use Sanity TypeGen to generate TypeScript types from GROQ queries, then import those types at the fetch site.
The pattern I follow for every query module:
// sanity/lib/queries.ts
import { defineQuery } from 'groq'
export const postBySlugQuery = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
publishedAt,
"slug": slug.current,
body,
mainImage { asset->, hotspot, crop }
}
`)
export const postListQuery = defineQuery(`
*[_type == "post"] | order(publishedAt desc) [0...$limit] {
_id,
title,
publishedAt,
"slug": slug.current,
"excerpt": pt::text(body)[0..120]
}
`)After running sanity typegen generate, I get PostBySlugQueryResult and PostListQueryResult in sanity/types.ts. Every component that touches post data uses those types. If a schema field gets renamed, TypeScript catches every broken usage before CI runs.
Route organisation that scales
I use Next.js route groups to separate concerns without adding URL segments:
app/
(marketing)/
page.tsx # homepage
about/page.tsx
layout.tsx # marketing header/footer
(content)/
blog/
page.tsx # list
[slug]/page.tsx # detail
layout.tsx # content-specific layout (breadcrumbs, etc.)
(app)/
dashboard/
page.tsx
layout.tsx # auth-gated layout
layout.tsx # root layout (fonts, providers)Each group gets its own layout, so the marketing shell never leaks into the dashboard shell. Route groups also let me colocate related server actions and route handlers near the pages that use them rather than dumping everything into a top-level api/ folder.
For route handlers I keep them thin — parse input, call a service function, return a typed response. The logic lives in lib/, not in the handler itself.
Error and loading conventions
Every segment that does async work gets three files: page.tsx, loading.tsx, and error.tsx. Not optional — I add them at project creation.
loading.tsx renders a skeleton that matches the page's visual weight. I size skeleton blocks to match real content dimensions, which prevents CLS when the real content loads. For image-heavy pages the skeleton includes a placeholder div at the same aspect ratio as the image.
error.tsx must be a client component (it receives the error prop and a reset function). I keep it simple:
// app/blog/[slug]/error.tsx
'use client'
interface Props {
error: Error & { digest?: string }
reset: () => void
}
export default function PostError({ error, reset }: Props) {
return (
<div className="py-24 text-center">
<p className="text-sm text-neutral-500">
Something went wrong loading this post.
</p>
<button
onClick={reset}
className="mt-4 text-sm underline"
>
Try again
</button>
{process.env.NODE_ENV === 'development' && (
<pre className="mt-4 text-xs text-red-500">{error.message}</pre>
)}
</div>
)
}I never leak error messages in production. The digest value is logged server-side by Next.js automatically, so I can correlate client errors with server logs without exposing stack traces.
Typed server actions for mutations
For forms and mutations I use server actions rather than route handlers so I stay in the RSC model. I wrap every action in a result type so the client component always has a typed response to work with:
// lib/actions/subscribe.ts
'use server'
import { z } from 'zod'
const schema = z.object({ email: z.string().email() })
export type ActionResult =
| { ok: true }
| { ok: false; error: string }
export async function subscribeAction(
_prev: ActionResult | null,
formData: FormData,
): Promise<ActionResult> {
const parsed = schema.safeParse({ email: formData.get('email') })
if (!parsed.success) {
return { ok: false, error: 'Invalid email address.' }
}
// call SendGrid / your email service here
return { ok: true }
}The client component calls this with useActionState (React 19) and renders feedback based on the discriminated union. No try/catch in the component, no raw error strings.
What this skeleton buys on a real project
These patterns aren't clever. They're boring and consistent, which is exactly the point. When a new developer joins a project or I return to a codebase after three months, the file layout is predictable, the data flow is traceable from query definition to render, and TypeScript catches regressions at the type level before they reach QA. That consistency is worth more than any individual optimisation.