How to build a scalable React app with Next.js and TypeScript
By Adam Hultman · 11 min read

Getting a Next.js app running with TypeScript is easy.
Keeping it pleasant to work in after six months of product changes is the harder part.
That’s where a lot of React apps start to creak. Not because React is bad, or Next.js is bad, or someone picked the wrong folder structure on day one. Usually it happens slowly. A page starts doing too much. A shared component gets one too many business rules. A hook becomes the secret home for fetching, formatting, permissions, and modal state. Suddenly every “small change” feels a little risky.
So when I think about building a scalable React app, I’m mostly thinking about one thing:
Can this codebase survive growth without becoming annoying to change?
Here are the patterns I’d reach for.
Start with Next.js and TypeScript, but don’t stop there
Creating a new Next.js app with TypeScript is still straightforward:
1
npx create-next-app@latest my-scalable-app --typescriptThat gives you a solid starting point: routing, builds, TypeScript, linting, and a bunch of defaults that save you from wiring everything up by hand.
But the framework does not make the architecture good for you. It just gives you the pieces.
TypeScript helps a lot, especially once the app grows. It catches silly mistakes, makes refactors less scary, and gives you better editor support. But it is not magic. If the boundaries in your app are messy, TypeScript will mostly help you describe the mess more accurately.
The real win is using TypeScript to make important things explicit:
1
2
3
4
5
type Project = {
id: string;
name: string;
status: "draft" | "active" | "archived";
};Small stuff like this pays off. It makes the code easier to read, easier to autocomplete, and harder to accidentally misuse.
Organize by feature when the app starts growing
A lot of tutorials recommend a structure like this:
1
2
3
4
5
6
/src
/components
/hooks
/services
/utils
/typesThat is totally fine for a small app. I’ve used it plenty of times.
The problem is that it can get vague fast. After a while, /components has 200 files, /utils becomes a junk drawer, and nobody knows whether a hook is safe to reuse or secretly tied to one page.
For apps that are going to grow, I prefer organizing most things by feature:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/src
/app
/features
/billing
/components
/hooks
/server
/types
/utils
/projects
/components
/hooks
/server
/types
/utils
/components
/ui
/layout
/lib
/typesThe idea is simple: if code only exists because of one feature, keep it with that feature.
A billing form, billing types, billing API helpers, and billing-specific components probably belong together. They change together, so they should live together.
Shared code should earn its way into shared folders. A button can be shared. A modal can be shared. A billing-specific price formatter probably should not become global just because another page might maybe use it someday.
That “maybe” is how codebases get weird.
Keep pages boring
Pages should mostly wire things together.
That sounds obvious, but it is easy to mess up. A page starts small, then slowly collects everything: fetching, validation, formatting, analytics, permissions, loading states, error handling, and five different pieces of UI state.
At first, that feels convenient. The file is right there.
Later, it becomes the file everyone is afraid to touch.
A cleaner pattern is to let the page handle routing and hand off the real work:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app/projects/[id]/page.tsx
import { ProjectScreen } from "@/features/projects/components/project-screen";
import { getProject } from "@/features/projects/server/get-project";
export default async function ProjectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const project = await getProject(id);
return <ProjectScreen project={project} />;
}The page knows about the URL. The feature knows how to render the product experience. The server function knows how to load the data.
That separation is not fancy, but it keeps changes easier to reason about.
Be careful with client components
With modern Next.js, one of the easiest ways to make an app heavier than it needs to be is to mark too much code as client-side.
Sometimes you need a client component. If something uses browser APIs, event handlers, useState, useEffect, or interactive UI, then yes, it probably needs "use client".
But not everything does.
A good default is:
1
2
3
4
5
6
// Server component
export default async function DashboardPage() {
const data = await getDashboardData();
return <Dashboard data={data} />;
}Then keep the interactive parts smaller:
1
2
3
4
5
"use client";
export function DashboardFilters() {
// filter state, clicks, dropdowns, browser interaction
}This keeps more work on the server and less JavaScript in the browser. It also makes the app easier to reason about because data loading is not scattered across every component.
The trap is turning a whole page into a client component because one small child needs state. It works, but it is usually not the best long-term shape.
Separate server state from UI state
State management gets messy when every kind of state is treated the same.
Not all state is equal.
Some state is local UI state:
1
const [isOpen, setIsOpen] = useState(false);Some state comes from the server:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useQuery } from "@tanstack/react-query";
export function useProjects() {
return useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await fetch("/api/projects");
if (!response.ok) {
throw new Error("Failed to load projects");
}
return response.json();
},
});
}Some state is truly global, like the current user, theme, or feature flags.
The trouble starts when those all get mixed together. A context provider starts holding server data. A hook starts managing form state and cache invalidation. A page starts passing the same object through seven layers.
For most apps, I’d keep it boring:
- Use
useStateanduseReducerfor local UI state. - Use TanStack Query or SWR for server state in client components.
- Use context sparingly for values that really are app-wide.
- Reach for Zustand or Redux Toolkit only when the client-side state is complex enough to justify it.
The goal is not to use fewer libraries. The goal is to make it obvious who owns the data.
Keep API routes thin
Next.js makes it easy to put backend logic close to your frontend. That is useful, but it can also become a dumping ground.
A route handler should usually do a few things:
- Read the request.
- Validate the input.
- Call the real business logic.
- Return a response.
Something like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// app/api/projects/route.ts
import { NextResponse } from "next/server";
import { createProject } from "@/features/projects/server/create-project";
import { createProjectSchema } from "@/features/projects/schemas/create-project-schema";
export async function POST(request: Request) {
const body = await request.json();
const parsed = createProjectSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", issues: parsed.error.flatten() },
{ status: 400 }
);
}
const project = await createProject(parsed.data);
return NextResponse.json(project, { status: 201 });
}The important part is that createProject lives somewhere else.
That makes it easier to test, reuse, and change later. If all the business logic lives inside the route handler, you’ll eventually want to call it from somewhere else and realize you have to copy half the endpoint.
Routes should be adapters, not the whole application.
Validate inputs at the edges
TypeScript is great, but it only helps at build time. It does not protect you from bad request bodies, weird form submissions, broken webhooks, or third-party APIs returning something unexpected.
For anything crossing a boundary, runtime validation is worth it.
1
2
3
4
5
6
import { z } from "zod";
export const createProjectSchema = z.object({
name: z.string().min(1),
customerId: z.string().uuid(),
});I like validating:
- API requests
- Form submissions
- Webhooks
- Environment variables
- Important external API responses
This is one of those things that feels like extra work until it saves you from debugging some mysterious undefined is not an object issue in production.
A clear validation error near the edge of the system is way better than a weird crash five layers later.
Watch out for “reusable” components
Reusable components are great.
Over-reusable components are not.
A simple shared button is good:
1
<Button variant="primary">Save</Button>A product-specific button is also good:
1
<UpgradeSubscriptionButton accountId={account.id} />The danger zone is a component that pretends to be generic but slowly collects business logic:
1
2
3
4
5
6
7
8
<Button
variant="primary"
accountId={account.id}
requiresSubscription
upgradePlan="pro"
>
Upgrade
</Button>Now your button knows about billing. Later it knows about permissions. Then analytics. Then loading states for one specific mutation.
The rule I like: shared UI components should mostly care about presentation and interaction. Product rules should live closer to the product feature.
Don’t hide everything in hooks
Custom hooks are useful, but they can become hiding places.
This is a good hook:
1
2
3
function useDebouncedValue<T>(value: T, delay: number) {
// small, clear, reusable behaviour
}This is more suspicious:
1
2
3
4
5
6
7
8
function useProjectPage(projectId: string) {
// fetches data
// checks permissions
// manages modals
// tracks analytics
// formats data
// handles mutations
}That might be fine for a while, but eventually it becomes a page component wearing a fake moustache.
Hooks are best when they isolate one clear behaviour. If a hook is coordinating half the page, it might be a sign that the page needs better boundaries.
Build failure states early
The happy path is not enough.
Real apps need to handle boring failures well:
- A request fails.
- A user double-clicks submit.
- A third-party API is slow.
- A form has invalid data.
- A page has no results.
- A deploy breaks something subtle.
Build those states before production users discover them for you.
For data-heavy screens, make sure you have:
- Loading states
- Empty states
- Error states
- Retry behaviour where it makes sense
- Useful logs when something breaks
For forms, handle duplicate submissions and unclear errors. “Something went wrong” is sometimes unavoidable, but it should not be your entire error strategy.
This is one of the places where polish really shows. A good failure state makes the app feel cared for.
Make performance a habit, not a rescue mission
Next.js gives you a lot out of the box, but performance still needs attention.
A few habits help:
- Keep client components smaller than you think they need to be.
- Avoid shipping huge libraries to the browser for tiny features.
- Use image optimization.
- Dynamically load heavy components when they are not needed right away.
- Track real user performance, not just Lighthouse scores.
- Check bundle impact when adding dependencies.
For example:
1
2
3
4
5
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./heavy-chart"), {
loading: () => <p>Loading chart...</p>,
});That can help when a chart, editor, map, or dashboard widget is not needed on the initial load.
But don’t go wild with this stuff. Lazy loading everything can make the app feel jumpy and complicated. Measure first, then optimize the parts that actually matter.
Same with useMemo and useCallback. They are useful, but sprinkling them everywhere does not automatically make an app faster. Sometimes it just makes the code uglier.
Put the boring checks in CI
If the app matters, do not rely on people remembering every rule in code review.
Let CI handle the boring stuff:
- Type checking
- Linting
- Formatting
- Unit tests
- Build checks
- Important integration tests
As the app grows, you can add more guardrails:
- Bundle size checks
- Accessibility checks
- Dependency audits
- Generated type drift checks
- Migration safety checks
The point is not to make CI fancy. The point is to make the quality bar automatic.
Reviewers should spend their time thinking about product behaviour, architecture, risk, and clarity. They should not have to manually catch the same preventable issues over and over.
Write down the patterns
This sounds boring, but it helps a lot.
When the app gets bigger, people need to know how things are supposed to be done:
- Where does server logic go?
- When do we use route handlers?
- When do we use server actions?
- How do we structure feature folders?
- How do forms work?
- How do we handle errors?
- What belongs in shared components?
- How do we add a new API integration?
You do not need a giant internal wiki. A few short docs in the repo can go a long way.
The best docs are close to the code and practical enough that someone can use them while building. If the docs are too abstract, nobody reads them. If they are too detailed, they rot.
A good codebase teaches you how to work inside it.
The real goal
A scalable React app is not just one that can handle more users.
It is one that can handle more change without making every ticket feel haunted.
New features should have an obvious home. Server logic should be easy to find. Components should not need a full company onboarding to render a button. Data should have a clear owner. Risky changes should trip checks before they become someone’s 9:47 PM production flashback.
Next.js and TypeScript are a great start, but the real work is in the habits around them.
That is what keeps the codebase boring in the best possible way.
Keep reading
React: compound components vs render props vs custom hooks
Oct 20, 2024
Building real-time applications with React and WebSockets
Oct 18, 2024
Top mistakes to avoid when deploying react apps to production
Oct 18, 2024