Alright, let’s get our Next.js house in order for TypeScript. This isn’t just about slapping a tsconfig.json file in there and calling it a day. We’re building the foundation for a full-stack TypeScript monolith, and a solid foundation means you don’t have to worry about the walls caving in later when you’re trying to deploy a server action at 2 AM.

First, the good news: if you bootstrapped your project with create-next-app@latest and said yes to TypeScript, you got a pretty decent tsconfig.json out of the box. The Next.js team has done a solid job pre-configuring the compiler for their specific environment. But you, my brilliant friend, are not a default kind of person. You need to know what these knobs do.

The tsconfig.json That Next.js Gives You

Your starter tsconfig.json probably looks something like this. Let’s not just glance at it; let’s really see it.

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "es6"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }]
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.d.ts"],
  "exclude": ["node_modules"]
}

Let’s call out the all-stars. "strict": true is non-negotiable. Turning this off to silence errors is like disabling your smoke alarm because your toast is burning. Fix the toast. "skipLibCheck": true is a performance lifesaver that skips type checking all your node_modules—trust me, you don’t want to type check React itself. The "next" plugin is Next.js’s secret sauce for things like type-checking getStaticProps. And that include path for .next/types/**/*.d.ts? That’s how Next.js automatically generates types for your pages. It’s magic, and we appreciate magic.

The Two Faces of a Next.js Page: Component and Data Functions

Here’s where the rubber meets the road. A page in Next.js is fundamentally two things: 1) a React component, and 2) the optional data-fetching functions that feed it props (getStaticProps, getServerSideProps, etc.). Their types are beautifully, elegantly intertwined.

You must type both halves. Here’s the canonical, bulletproof way to do it for a page that uses getStaticProps:

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

// First, type the props your component will actually receive
interface PostPageProps {
  post: {
    id: string;
    title: string;
    content: string;
  };
}

// If you're using dynamic routes, you MUST type the params (Query is a misnomer here, it's the params)
interface PostPageParams extends ParsedUrlQuery {
  id: string;
}

// Now, type the getStaticProps function itself.
// We use `GetStaticProps` from 'next', and we genericize it with:
// 1. The props for the component (PostPageProps)
// 2. The params it expects (PostPageParams)
export const getStaticProps: GetStaticProps<PostPageProps, PostPageParams> = async (context) => {
  const { id } = context.params!; // We know params exists because of getStaticPaths

  // ... fetch your data from your CMS or DB here ...
  const post = await fetchPostFromDatabase(id);

  // The moment of truth. Return your props.
  // Forget this shape and the universe will collapse.
  return {
    props: {
      post, // This must match the PostPageProps interface
    },
    revalidate: 60, // ISR for the win
  };
};

// Type getStaticPaths as well. The `paths` array must match the structure of your params.
export const getStaticPaths: GetStaticPaths<PostPageParams> = async () => {
  const posts = await fetchAllPostSlugs();
  const paths = posts.map((post) => ({
    params: { id: post.id }, // This creates the array of { params: { id: '1' } } objects
  }));

  return {
    paths,
    fallback: 'blocking', // or true/false
  };
};

// Finally, the component itself. The props it receives are precisely what getStaticProps returned.
const PostPage: NextPage<PostPageProps> = ({ post }) => {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
};

export default PostPage;

The beauty of NextPage<PostPageProps> is that it makes the connection explicit. If getStaticProps returns { props: { title: 'Hi' } } but your component expects { post: ... }, TypeScript will throw a fit at the component level. It’s a fantastic, redundant check that saves you from yourself.

The App Router and Its Newfangled Ways

The /app directory changes the game. Data fetching is now done with async Server Components, and the typing story is both simpler and, in a way, more implicit.

// app/posts/[id]/page.tsx
interface PageProps {
  params: Promise<{ // Note: In App Router, params and searchParams are PROMISES
    id: string;
  }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

// This is a Server Component, so it can be async.
// We destructure the resolved params from the props.
export default async function PostPage({ params }: PageProps) {
  const { id } = await params; // You have to await the params promise. It's weird, but you get used to it.
  const post = await fetchPostFromDatabase(id); // Fetch directly in the component!

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

// Need to generate static params? Simpler than getStaticPaths.
export async function generateStaticParams(): Promise<PageProps['params'][]> {
  const posts = await fetchAllPostSlugs();
  return posts.map((post) => ({ id: post.id })); // Just return an array of objects matching your params.
}

The Promise wrapping on params and searchParams is the biggest “wait, what?” moment. It’s because these values are resolved as part of the React Server Component promise chain. Just remember to await them. Always.

The Pitfalls: Where You Will Screw This Up

  1. Forgetting fallback in getStaticPaths. TypeScript won’t save you here. If you’re using fallback: true, your page component must handle the case where the post is undefined while the static path is being generated in the background. Handle it, or use fallback: 'blocking'.
  2. Ignoring searchParams in the App Router. Your PageProps should almost always include `searchParams: Promise<…>`` even if you don’t use them, just for consistency.
  3. Mixing up params and query. In the Pages Router, context.params is for dynamic route segments (/posts/[id]). context.query is for the actual URL query string (?sort=asc). They are different things. Type them accordingly.

Get this configuration right, and the rest of your full-stack journey becomes a joy. Get it wrong, and you’ll be fighting type errors instead of building features. I know which one you’d rather be doing.