By Adam Hultman
Getting started with Next.js and TypeScript is straightforward, but a few tweaks can save you a lot of headaches down the road. The official documentation makes it easy, but here’s a quick refresher:
First, let’s create a new Next.js app with TypeScript:
1
npx create-next-app@latest my-scalable-app --typescript
This command sets up a new Next.js app with TypeScript configured out of the box. If you already have an existing Next.js project and want to add TypeScript, just add a tsconfig.json
file at the root, and Next.js will automatically configure it for you.
The benefit of using TypeScript with Next.js isn’t just the type safety (though that’s a big one). It also helps with catching bugs early, providing better IntelliSense in editors like VS Code, and making your code more self-documenting. But make no mistake—TypeScript can feel like a pain if you dive in too deep without understanding the basics, so take your time getting used to it.
As your app grows, having a well-organized project structure becomes crucial. It’s tempting to just drop everything into a single pages
directory, but that can get messy fast. Here’s a structure that has worked well for me:
1
2
3
4
5
6
7
8
9
10
/src
/components
/hooks
/layouts
/pages
/services
/utils
/styles
/context
/types
This setup keeps related logic together and makes it easy to find what you need. Here’s a quick rundown:
MainLayout
for the default layout).fetch
or axios
.React Context
.By separating out the logic into these folders, you can keep your components lean and your pages focused on rendering. This structure is flexible enough to grow with the project and makes onboarding new team members easier—they can find what they need without a scavenger hunt.
One of the best parts about Next.js is its built-in support for API routes. If you’re coming from a Create React App setup, this feels like a game changer. It allows you to create serverless functions right alongside your frontend code.
For example, say you’re building a feature that fetches blog posts from an external API. You can create an API route like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// /src/pages/api/posts.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
res.status(405).json({ message: 'Method not allowed' });
return;
}
try {
const response = await fetch('https://api.example.com/posts');
const posts = await response.json();
res.status(200).json(posts);
} catch (error) {
res.status(500).json({ message: 'Error fetching posts' });
}
}
This approach keeps your API logic close to the components that use it. It also helps when you want to protect certain routes, like adding authentication checks before fetching data.
But remember: these API routes are still serverless functions, so try to keep them light. If you need to do something more complex, consider using a dedicated backend like Node.js with Express or deploying a separate server with a service like AWS Lambda.
State management in a scalable React app can get tricky, especially when you have a mix of local state and data that needs to be shared across components. For most apps, you’ll need a combination of React’s built-in useState
and useReducer
for local state, React Context
for global state, and a library like React Query
or SWR
for data fetching.
React Query is my go-to for data fetching, caching, and synchronizing server state. It simplifies things like pagination, background refetching, and managing the loading state:
1
2
3
4
5
6
7
8
9
10
11
import { ueQuery } from 'react-query';
const usePosts = () => {
return useQuery('posts', async () => {
const response = await fetch('/api/posts');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
};
With React Query
, you don’t need to write much boilerplate code for handling loading, error states, or caching. It also automatically refetches data when the user revisits a page or regains network connection.
For global state, React Context
works well when you have a small number of global values, like user authentication status. But if your app has more complex state needs, you might want to look into Zustand or Redux Toolkit. Both offer more structure and are better suited for large-scale apps.
Next.js is optimized for deployment on Vercel, but you can easily deploy it on AWS using services like Amplify or Elastic Beanstalk if you need more control. Here’s what you should keep in mind for each:
Vercel: It’s by far the easiest option, especially since Next.js was created by the team behind Vercel. Deployment is as simple as pushing your code to GitHub—Vercel will automatically build and deploy your site. It’s also great for features like server-side rendering and API routes, which Vercel handles out of the box.
AWS Amplify: If you’re already using AWS for other parts of your stack, Amplify can be a good fit. It takes a bit more setup than Vercel, but it offers more flexibility in terms of managing backend services like databases and authentication through Cognito.
In either case, don’t forget to set up environment variables for things like API keys and database URLs. Vercel and AWS Amplify both support this through their dashboards, allowing you to manage secrets securely.
Performance is often the difference between a decent app and one that users actually enjoy using. Next.js makes some optimizations for you, but there are a few things you should consider:
Image
component, which automatically optimizes images for different screen sizes and formats. It’s one of the easiest ways to shave off load time, especially for media-heavy pages.React.lazy
and Suspense
:1
2
3
4
5
6
7
8
9
const SomeHeavyComponent = React.lazy(() => import('./SomeHeavyComponent'));
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SomeHeavyComponent />
</Suspense>
);
}
This way, you can load heavy components only when they’re needed, reducing the initial bundle size.
Building a scalable React app with Next.js and TypeScript takes some planning, but the payoff is worth it. By keeping your codebase organized, leveraging the power of TypeScript, and using Next.js’s built-in optimizations, you can create an app that not only performs well today but is also ready to grow with your needs. Whether you’re building a side project or a production-grade application, these best practices will help keep you on the right track.