Go to blue arrow
back to Tech Blog
Development

Written by:

Anjali Ariscrisnã
Anjali Ariscrisnã

,

Senior Growth Specialist

Admilson Cruz
Admilson Cruz

,

Developer

Last Published:

06 August 2026

Min Read

Using the Next.js framework with TypeScript

NEXT.JS logo plus TypeScript wordmark on white, showing how using Next.js TypeScript fits together.

Two tools, two entirely different problems. The Next.js framework solves a rendering problem: getting fully formed HTML in front of search engines and users without giving up the interactivity of a React app. TypeScript solves something else entirely, which is knowing the shape of your data before a single line of it runs.

So should you use both? Only under one condition: your codebase will outlive the team that wrote it. That is the whole test, and we will come back to it. First, what each tool actually does, then how to wire them together.

blue arrow to the left
Imaginary Cloud logo

What is the Next.js framework?

Next.js is an open-source framework created by Vercel. It claims to be the Web's Software Development Kit with all the tools needed "to make the Web. Faster" (sic). Learn about Next.js features with React and its applications here.

What is Next.js used for?

Next.js lets search engines optimise React apps with very little setup on your part. Picture what a traditional React app sends first: a shell of an HTML page with nothing rendered inside it.

The browser then fetches the JavaScript file carrying your React code, renders content into the DOM, the browser's live tree of page elements, and makes it interactive. That works. It also has two drawbacks worth taking seriously:

  • The content is not reliably indexed by all search engines or read by social media link bots. Google's own documentation explains why JavaScript-rendered pages need a second processing pass before they are indexed.
  • It can take longer to reach the first contentful paint, the moment the browser paints the first piece of real content on screen rather than a blank shell.

Next.js lets you build a React app but render the content in advance on the server, so the first thing a user or a search bot sees is the fully rendered HTML. Once that initial page lands, client-side rendering takes over and the app behaves like any other React app.

Fully rendered content for bots. Highly interactive content for users. One codebase.

Client-side data fetching using the Next.js framework

Data fetching is where the Next.js framework earns its keep, because it can run several server rendering strategies from a single project.

Client-side fetching suits pages that do not need SEO indexing, do not need pre-rendered data, or change too often to be worth freezing. Static generation, also called pre-rendering, builds your pages once at build time rather than on every request. Here is the client-side version:

import { useEffect, useState } from 'react';

type Todo = { id: number; text: string; done: boolean };

export default function TodoList() {
  const [isLoading, setIsLoading] = useState(true);
  const [todos, setTodos] = useState<Todo[]>([]);

  useEffect(() => {
    fetch('/api/todos')
      .then((response) => response.json())
      .then((data: Todo[]) => {
        setTodos(data);
        setIsLoading(false);
      });
  }, []);

  if (isLoading) return <p>Loading...</p>;

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

That is client-side data fetching using React's useEffect hook. We initialise two constants, one to track whether the fetch is still pending and one to hold the result, then call useEffect with two arguments:

  • Callback — the function holding the side-effect logic that runs right after changes are pushed to the DOM. Here, that logic fetches data from an endpoint and saves it into our constant.
  • Dependencies — an array specifying when the callback should run. An empty array means run it once.

One detail worth catching. That Todo[] annotation on the response is a promise, not a guarantee: it tells the compiler what you expect the endpoint to send back, and nothing checks it at runtime. Later on we type the API route itself, so the promise is enforced at both ends.

blue arrow to the left
Imaginary Cloud logo

What is TypeScript?

TypeScript is a programming language developed and maintained by Microsoft. It is a strict superset of JavaScript, which means every valid JavaScript file is already valid TypeScript. Nothing to convert on day one.

Think of it as labelling the boxes when you move house. You can move without labels, and you will eventually find out what is in each one. TypeScript is the marker pen: it supports static and dynamic typing, adds inheritance features, classes and interfaces on top, and was built for larger projects because it makes code far easier to refactor. Learn more about its features with an in-depth comparison with JavaScript.

blue arrow to the left
Imaginary Cloud logo

What do you use TypeScript for in a Next.js project?

Plenty of reasons a JavaScript developer makes the move:

  • Using new features of ECMAScript — TypeScript supports current ECMAScript standards and transpiles them, meaning it converts them into an older syntax that browsers already understand. Write modules, arrow functions and destructuring, ship code that runs in older browsers.
  • Static typing — JavaScript is dynamically typed and has no idea what a variable holds until it is instantiated at runtime. TypeScript moves that question forward to compile time.
  • Type inference — the compiler works out a variable's type from the value you assign it, so you write fewer annotations than you would expect. Even the types you never wrote are there, catching what would otherwise be a runtime error.
  • Better IDE support — types drive autocomplete, jump-to-definition and safe renaming. Visual Studio and VS Code, IntelliJ, Sublime and WebStorm all support TypeScript out of the box.
  • Strict null checking — "you cannot read a property 'x' of undefined" is the most familiar error in JavaScript. Strict checking catches most of that family, because you cannot use a variable the compiler does not know about.
  • Interoperability — TypeScript sits close enough to JavaScript to work with its whole ecosystem, though untyped libraries need type declarations before the compiler can help you with them.

Type safety is one delivery discipline among several. If you are reviewing how your team works end to end, our guide to the 18 best Agile practices to use in your software development cycle covers the rest.

18 best Agile practices to use in your Software Development Cycle banner with a woman holding sticky notes for a SaaS app.
blue arrow to the left
Imaginary Cloud logo

Should you use the Next.js framework with TypeScript?

The technical case is well documented. The commercial case is the one that actually decides it, and it is worth settling before anyone writes a tsconfig.json.

We call our check the outlives test: three questions about whether a codebase will outlive the people who wrote it. Run them on your own project.

1. Where do your defects get caught? A type error is caught by the compiler, in the developer's editor, seconds after it is written. The same mistake in plain JavaScript waits until runtime, which in practice means QA, staging, or production. Each step down that chain costs more to diagnose and more to fix. The last one costs you a release.

That category is measurable, which is unusual for this kind of argument. Airbnb reviewed its own past incidents during its migration and reported at JSConf Hawaii in 2019 that 38 per cent of the bugs it had shipped would have been preventable by TypeScript. An independent study, To Type or Not to Type: Quantifying Detectable Bugs in JavaScript (Gao, Bird and Barr, ICSE 2017), sampled public bug fixes from GitHub and put the figure at 15 per cent. Treat the lower number as your floor and the higher one as what a large, long-lived codebase looks like.

2. How fast can a stranger change your code? On an inherited codebase, types are the documentation that cannot go stale. A developer joining a typed Next.js project follows a prop from a page into a component and sees exactly what it carries. On an untyped one, they read the call sites, guess, and find out in review whether the guess held.

3. What does the codebase cost to change in year three? This is where TypeScript really pays. Renaming a field or reshaping an API response becomes a compiler-guided task instead of a manual search across the repository. That matters enormously on a long-lived product, and barely at all on a marketing site with a six-month life.

Our rule at Imaginary Cloud falls straight out of the test. If a codebase will outlive the team that wrote it, we start it in TypeScript. If it is a throwaway prototype, we usually do not, because the setup cost is real and the payoff never arrives.

blue arrow to the left
Imaginary Cloud logo

How do I install TypeScript in a Next.js framework project?

Here is a step-by-step TypeScript tutorial for a Next.js app, from an empty directory to a typed page. It uses the Pages Router, which is what most existing production codebases still run on. The App Router section below covers the newer model.

The point of this walkthrough is not the todo app. It is the typed contract between the API route and the page that consumes it, which is the part most tutorials skip and, conveniently, the part that pays for itself.

1. Create the base project. Run npx create-next-app@latest my-todo-app to create a project from the base template.

2. Add tsconfig.json into the root of the project to activate TypeScript. Next.js spots the file on your next npm run dev, installs the TypeScript dependencies it needs, and fills the file in for you. A generated configuration looks like this:

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "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" }],
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

Leave strict on. Switching it off is the single most common way a team ends up with a typed codebase that catches nothing. The plugins line enables the Next.js TypeScript language-server plugin, and the paths alias is what makes @/… imports resolve — the App Router examples below rely on it.

3. Structure the project in the form below.

my-todo-app/
├── app/                      # App Router (see the section below)
│   └── api/
│       └── todos/
│           └── route.ts
├── components/
│   ├── TodoForm.tsx
│   └── TodoItem.tsx
├── pages/                    # Pages Router (this walkthrough)
│   ├── api/
│   │   └── todos.ts
│   ├── _app.tsx
│   └── index.tsx
├── types/
│   └── todo.ts
├── next-env.d.ts
├── package.json
└── tsconfig.json

4. Create TypeScript types in Next.js.

You can type anything in your application: props, API responses, function arguments. Put the ones that cross a boundary in their own file, because that is what makes them shareable.

Start with a type for our Todo:

// types/todo.ts
export type Todo = {
  id: number;
  text: string;
  done: boolean;
};

5. Type the API route with the same type.

This is the step that turns TypeScript from an editor convenience into a contract. The route handler declares that it returns Todo[]. The page that calls it declares that it receives Todo[]. Both read the same file.

// pages/api/todos.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { Todo } from '../../types/todo';

const todos: Todo[] = [
  { id: 1, text: 'Type the API route', done: true },
  { id: 2, text: 'Ship it', done: false },
];

export default function handler(
  _request: NextApiRequest,
  response: NextApiResponse<Todo[]>
) {
  response.status(200).json(todos);
}

The payoff arrives the day somebody renames text to label in types/todo.ts. The route handler stops compiling, because the objects in todos no longer match Todo[], and so does every component reading todo.text. Without the shared type? The rename compiles cleanly and the page quietly renders a column of blanks.

6. Create components in Next.js.

Now that we have our Todo type, we can build the TodoItem component.

// components/TodoItem.tsx
import { Todo } from '../types/todo';

type TodoItemProps = {
  todo: Todo;
  removeTodo: (id: number) => void;
  setDone: (id: number) => void;
};

export default function TodoItem({ todo, removeTodo, setDone }: TodoItemProps) {
  return (
    <li>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => setDone(todo.id)}
      />
      <span>{todo.text}</span>
      <button onClick={() => removeTodo(todo.id)}>Remove</button>
    </li>
  );
}

We import the type we created, then declare a second one, TodoItemProps, mirroring the props the component receives.

This component displays a Todo object. It takes that object, a removeTodo function and a setDone function as props. The argument has to match the props type, or the compiler rejects it before the code ever runs.

Now the TodoForm component, responsible for adding todos.

// components/TodoForm.tsx
import { FormEvent, useState } from 'react';

type TodoFormProps = {
  addTodo: (text: string) => void;
};

export default function TodoForm({ addTodo }: TodoFormProps) {
  const [value, setValue] = useState('');

  const handleSubmit = (event: FormEvent) => {
    event.preventDefault();
    if (!value.trim()) return;
    addTodo(value);
    setValue('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={value} onChange={(event) => setValue(event.target.value)} />
      <button type="submit">Add todo</button>
    </form>
  );
}

It accepts an addTodo function as a prop and handles submission of a new todo. If the value is not empty, it calls addTodo with the text and clears the form.

Look at the signature on that prop: (text: string) => void. The parent cannot pass a function expecting an object, or one expecting two arguments, without the compiler having something to say about it.

7. Create the page that uses the components.

We import the components and types from earlier, plus GetStaticProps, a type provided by Next.js that lets us type the getStaticProps method, the function Next.js runs at build time to fetch the data a statically generated page needs.

Then we initialise the todos state with the useState hook, passing in the initial todos getStaticProps provides, and declare the three functions carrying our logic:

  • addTodo — adds a todo to the list
  • removeTodo — removes a todo from the list
  • setDone — marks a todo as done

Finally, we render the list using our components.

// pages/index.tsx
import { GetStaticProps } from 'next';
import { useState } from 'react';
import TodoForm from '../components/TodoForm';
import TodoItem from '../components/TodoItem';
import { Todo } from '../types/todo';

type HomeProps = {
  initialTodos: Todo[];
};

export default function Home({ initialTodos }: HomeProps) {
  const [todos, setTodos] = useState<Todo[]>(initialTodos);

  const addTodo = (text: string) =>
    setTodos([...todos, { id: Date.now(), text, done: false }]);

  const removeTodo = (id: number) =>
    setTodos(todos.filter((todo) => todo.id !== id));

  const setDone = (id: number) =>
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      )
    );

  return (
    <main>
      <TodoForm addTodo={addTodo} />
      <ul>
        {todos.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}
            removeTodo={removeTodo}
            setDone={setDone}
          />
        ))}
      </ul>
    </main>
  );
}

export const getStaticProps: GetStaticProps<HomeProps> = async () => {
  const response = await fetch('http://localhost:3000/api/todos');
  const initialTodos: Todo[] = await response.json();

  return { props: { initialTodos }, revalidate: 60 };
};

One caveat on that fetch: calling your own API over HTTP at build time is fragile, because the server may not be running when the page is generated. In a real build, import the data source directly inside getStaticProps instead of going through the network.

The page is typed end to end now, from route handler through props to rendered element. Change the shape of Todo in types/todo.ts and the compiler points at every file that no longer matches, before you have opened a browser.

blue arrow to the left
Imaginary Cloud logo

The App Router and TypeScript

Everything above uses the Pages Router. Since Next.js 13 the App Router has been the default for new projects, and as of Next.js 16 (October 2025) it is the standard the framework is built around — Turbopack is now the default bundler and the minimum is Node.js 20. The typing model shifts with the App Router. Starting a project today? This is the version to write.

Three things move:

  • Pages become server components. A page in app/ runs on the server by default and can be an async function, so data fetching happens inline. No getStaticProps, no props object to type: you type what you fetch, where you fetch it.
  • API routes become route handlers. pages/api/todos.ts becomes app/api/todos/route.ts, exporting a function named after the HTTP method and using the Web Request and Response objects rather than the Next.js-specific ones.
  • Caching replaces the rendering-mode choice. getStaticProps with revalidate becomes an option on the fetch call itself. The same shared type still spans both ends.

What changed in Next.js 16

If you last touched Next.js around the 13 or 14 releases, three things are worth knowing before you upgrade. Turbopack is now the default bundler for both dev and build, so cold starts and rebuilds are markedly faster and most projects without a custom webpack config need no changes. The minimum supported Node.js version is 20. And the caching model is now explicit: fetch is no longer cached by default, so you opt in per call with cache and next.revalidate rather than relying on framework defaults. None of this changes the typed-contract pattern in this article — types/todo.ts is still the single definition both ends share — but it does change the commands you run. The upgrade codemod (npx @next/codemod@latest upgrade latest) handles most of the mechanical work; budget your time for the App Router migration and React 19 compatibility, not the version bump itself.

// app/api/todos/route.ts
import { NextResponse } from 'next/server';
import { Todo } from '@/types/todo';

const todos: Todo[] = [
  { id: 1, text: 'Type the route handler', done: true },
  { id: 2, text: 'Ship it', done: false },
];

export async function GET() {
  return NextResponse.json<Todo[]>(todos);
}
// app/page.tsx
import TodoItem from '@/components/TodoItem';
import { Todo } from '@/types/todo';

async function getTodos(): Promise<Todo[]> {
  const response = await fetch('http://localhost:3000/api/todos', {
    next: { revalidate: 60 },
  });
  return response.json();
}

export default async function Home() {
  const todos = await getTodos();

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

Same contract as before: types/todo.ts is the single definition, the route handler declares it returns that shape, the page declares it consumes it. One caveat before you migrate anything. Interactive components like TodoForm need the 'use client' directive at the top of the file, because state and event handlers cannot run on the server.

blue arrow to the left
Imaginary Cloud logo

Frequently asked questions

Is Next.js worth using with TypeScript?

For any application you expect to maintain beyond a few months, yes. Next.js has first-class TypeScript support built in, so the setup cost is close to zero, and the type safety pays back on every refactor and every new developer who joins the project.

Can I add TypeScript to an existing Next.js project?

Yes, and you can do it incrementally. Add a tsconfig.json file, run the dev server, and Next.js installs what it needs. With allowJs set to true, your existing .js files keep working while you convert files to .tsx one at a time.

Does TypeScript slow down Next.js builds?

Type checking adds time to the build, but Next.js does not type check in the dev server's hot reload path, so day-to-day development is unaffected. On large codebases you can move the check to a separate CI step with tsc --noEmit and keep the build itself fast.

Do I need TypeScript for a small Next.js site?

Probably not. A landing page or a short-lived campaign site rarely lives long enough to repay the setup and the annotation effort. The line falls at whether the codebase will be handed to someone who did not write it.

What is the difference between getStaticProps and getServerSideProps?

getStaticProps runs at build time and produces HTML once, which suits content that changes rarely. getServerSideProps runs on every request, which suits content that is personalised or changes constantly. Both are fully typed in TypeScript. On the App Router, both are replaced by caching options on fetch.

Should I use the App Router or the Pages Router with TypeScript?

Use the App Router for anything new, because it is the default and where the framework is heading. Keep an existing Pages Router codebase where it is unless you have a reason to move: both are supported, and they can coexist in the same project during a migration.

blue arrow to the left
Imaginary Cloud logo

Putting the Next.js framework and TypeScript to work

The setup cost is a tsconfig.json file and the discipline of typing your props. The return arrives on the third refactor, on the first new joiner, and on the first API change that would otherwise have shipped broken.

So run the outlives test on your own project before you commit. If the answers point at a codebase somebody else will maintain, the pairing pays for itself. If they point at a prototype, save yourself the effort and the marker pen.

If you are weighing this up for a product your team has to live with, we are happy to talk it through. We build web products on this stack — from the Geo Matrix Decision Engine for Aurora Analytica, a Next.js platform that lets clinical-research teams run trial-design scenarios on their own data, to AppTweak's dashboard, where rebuilding in React and TypeScript cut loading time by 80%. We'll give you a straight answer about whether the pairing fits yours.

blue arrow to the left
Imaginary Cloud logo
Anjali Ariscrisnã
Anjali Ariscrisnã

Versatile and data-driven Growth Marketer with in-depth business knowledge, updated with latest developments in the Digital Marketing landscape.

LinkedIn

Read more posts by this author
Admilson Cruz
Admilson Cruz

A young and passionate developer who is on a quest to make a difference in how people go about their daily lives.

LinkedIn

Read more posts by this author

People who read this post, also found these interesting:

Dropdown caret icon