Next.js & TypeScript: The Ultimate Revision Guide for React Developers

Next.js & TypeScript: The Ultimate Revision Guide for React Developers

Next.js is an advanced React framework that simplifies server-side rendering (SSR), static site generation (SSG), API routing, and more, all while optimizing performance. When combined with TypeScript, it offers static type-checking and improved developer productivity.

This tutorial is aimed at React developers who already have experience with React and TypeScript. We will dive into Next.js concepts, focusing on practical TypeScript integration throughout.


Setting Up Your Next.js Project with TypeScript

To get started with a new TypeScript-based Next.js project, run:

npx create-next-app@latest my-next-app --typescript

Or, if you're converting an existing project, install TypeScript:

npm install --save-dev typescript @types/react @types/node

Then rename your .js files to .ts/.tsx to enable type safety.


Next.js uses a custom Link component for client-side navigation between pages. This component is essential for client-side routing, enabling faster transitions by avoiding full-page reloads.

// pages/index.tsx
import Link from 'next/link';

const Home: React.FC = () => {
  return (
    <div>
      <h1>Home Page</h1>
      <Link href="/about">
        <a>Go to About Page</a>
      </Link>
    </div>
  );
};

export default Home;

The Link component wraps an anchor (<a>), and by default, Next.js handles the navigation, ensuring the page doesn’t refresh when clicking the link. This provides better performance and improved user experience.


Image Optimization with next/image

Next.js provides built-in image optimization via the Image component, which automatically optimizes images for web use (resizing, lazy loading, and WebP support).

import Image from 'next/image';

const MyImage: React.FC = () => {
  return (
    <div>
      <Image src="/me.png" alt="Profile" width={500} height={500} />
    </div>
  );
};

export default MyImage;

Key benefits:

  • Lazy loading: Loads images only when visible on the viewport.

  • Responsive: Automatically serves the appropriate image size based on the device.

  • WebP support: Delivers images in modern, optimized formats.


Data Fetching Strategies: getStaticProps, getServerSideProps, getStaticPaths

Next.js provides several methods for data fetching, enabling both static generation and server-side rendering.

Static Generation (getStaticProps)

For pages that can be pre-rendered at build time, use getStaticProps. This method fetches data and generates static pages for each request.

// pages/posts.tsx
import { GetStaticProps, NextPage } from 'next';

type Post = {
  id: number;
  title: string;
};

type Props = {
  posts: Post[];
};

const Posts: NextPage<Props> = ({ posts }) => {
  return (
    <div>
      {posts.map((post) => (
        <div key={post.id}>
          <h2>{post.title}</h2>
        </div>
      ))}
    </div>
  );
};

export const getStaticProps: GetStaticProps = async () => {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts = await res.json();

  return {
    props: {
      posts,
    },
    revalidate: 10, // ISR (revalidates every 10 seconds)
  };
};

export default Posts;

Here, the page is pre-rendered at build time but also revalidated at intervals with Incremental Static Regeneration (ISR) to ensure fresh data.

Server-Side Rendering (getServerSideProps)

For dynamic data that changes on every request, use getServerSideProps. This method runs on the server during each request.

import { GetServerSideProps } from 'next';

const SSRPage: React.FC = ({ data }) => {
  return <div>Data from server: {data}</div>;
};

export const getServerSideProps: GetServerSideProps = async () => {
  const res = await fetch('https://api.example.com/data');
  const data = await res.json();

  return {
    props: { data },
  };
};

export default SSRPage;

This approach is ideal when the data needs to be up-to-date with every request (like user data).


Dynamic Routes with TypeScript

Next.js supports dynamic routes based on the file system structure. Create dynamic routes by wrapping file names in square brackets ([param]).

// pages/posts/[id].tsx
import { GetStaticProps, GetStaticPaths } from 'next';

type Post = {
  id: number;
  title: string;
  body: string;
};

type Props = {
  post: Post;
};

const PostPage: React.FC<Props> = ({ post }) => {
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </div>
  );
};

export const getStaticPaths: GetStaticPaths = async () => {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts = await res.json();

  const paths = posts.map((post: Post) => ({
    params: { id: post.id.toString() },
  }));

  return { paths, fallback: false };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${params?.id}`);
  const post = await res.json();

  return {
    props: {
      post,
    },
  };
};

export default PostPage;

getStaticPaths generates static paths based on the available data, while getStaticProps fetches individual post data for each path.


Handling Errors and Loading States

Error handling is an essential part of data fetching, and showing loading indicators improves user experience.

import { useState, useEffect } from 'react';

const FetchDataComponent: React.FC = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const res = await fetch('https://api.example.com/data');
        if (!res.ok) {
          throw new Error('Failed to fetch data');
        }
        const result = await res.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;

  return <div>{JSON.stringify(data)}</div>;
};

export default FetchDataComponent;

Here, we handle loading states and errors during data fetching with hooks. This pattern is reusable for API calls in components.


Caching and Revalidation in Next.js

Next.js provides ISR (Incremental Static Regeneration), where static pages are revalidated based on time intervals. For server-side caching, Next.js allows full control over caching headers in getServerSideProps.

Example of Setting Cache Control

// In getServerSideProps
export const getServerSideProps = async ({ res }) => {
  const data = await fetchData();

  // Cache the page for 60 seconds
  res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate');

  return {
    props: { data },
  };
};

This ensures that the page is cached for 60 seconds while allowing for stale-while-revalidate behavior, which serves the stale page until a new one is generated.


Error Pages and Custom Error Handling

Next.js provides custom error pages for 404 and other errors using the _error.tsx file.

// pages/_error.tsx
import { NextPageContext } from 'next';

type Props = {
  statusCode: number;
};

const ErrorPage = ({ statusCode }: Props) => {
  return (
    <div>
      <h1>{statusCode ? `Error ${statusCode}` : 'An error occurred'}</h1>
    </div>
  );
};

ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => {
  const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
  return { statusCode };
};

export default ErrorPage;

This page displays a custom error message depending on the type of error.


Conclusion

This comprehensive guide covered key Next.js topics with TypeScript, from navigation with Link, image optimization with Image, and dynamic routing to essential concepts like error handling, data fetching (SSG, SSR), and caching strategies.

By mastering these techniques, you’ll be equipped to build robust, type-safe, and high-performance web applications with Next

.js and TypeScript. If you’re familiar with React and TypeScript, integrating these tools into your development workflow will help you build more scalable, maintainable applications.

Now it's your turn to take these concepts and apply them in your projects!

Happy coding!