contact us


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.
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.
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:
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.
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:
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.
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.
Plenty of reasons a JavaScript developer makes the move:
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.

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.
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.json4. 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 listremoveTodo — removes a todo from the listsetDone — marks a todo as doneFinally, 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.
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:
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.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.getStaticProps with revalidate becomes an option on the fetch call itself. The same shared type still spans both ends.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.
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.
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.
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.
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.
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.
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.
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.

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

A young and passionate developer who is on a quest to make a difference in how people go about their daily lives.
People who read this post, also found these interesting: