TypeScript with Next.js
Introduction
Next.js is a powerful React framework that enables features like server-side rendering, static site generation, API routes, and more. When combined with TypeScript, it provides a robust, type-safe environment for building modern web applications. This guide will walk you through setting up and using TypeScript in a Next.js application, highlighting the benefits and best practices along the way.
TypeScript in Next.js gives you:
- Type safety across your entire application
- Better developer experience with improved autocomplete
- Early error detection during development
- Enhanced maintainability for larger projects
- Built-in types for Next.js-specific features
Getting Started with TypeScript in Next.js
Setting Up a New Project
Creating a new Next.js project with TypeScript support is straightforward:
npx create-next-app@latest my-typescript-app --typescript
# or
yarn create next-app my-typescript-app --typescript
This command sets up a new Next.js project with TypeScript configuration already in place.
Converting an Existing Next.js Project
If you have an existing Next.js project, you can add TypeScript support by:
- Installing TypeScript and type definitions:
npm install --save-dev typescript @types/react @types/node
# or
yarn add --dev typescript @types/react @types/node
- Creating a
tsconfig.json
file in your project root:
touch tsconfig.json
- Running the development server - Next.js will automatically populate the config:
npm run dev
# or
yarn dev
Next.js-specific TypeScript Features
Page Components
In Next.js, pages are React components exported from files in the pages
directory. With TypeScript, you can use built-in types for pages:
// pages/index.tsx
import type { NextPage } from 'next';
const Home: NextPage = () => {
return (
<div>
<h1>Welcome to my TypeScript Next.js App!</h1>
</div>
);
};
export default Home;
GetServerSideProps with TypeScript
For server-side rendering, you can use types for getServerSideProps
:
// pages/ssr-example.tsx
import type { NextPage, GetServerSideProps } from 'next';
// Define the type for your page props
interface SSRPageProps {
serverTime: string;
}
const SSRPage: NextPage<SSRPageProps> = ({ serverTime }) => {
return (
<div>
<h1>Server-side Rendered Page</h1>
<p>This page was rendered on the server at: {serverTime}</p>
</div>
);
};
export const getServerSideProps: GetServerSideProps<SSRPageProps> = async () => {
// This runs on the server
const serverTime = new Date().toISOString();
return {
props: {
serverTime,
},
};
};
export default SSRPage;
GetStaticProps and GetStaticPaths
For static site generation, TypeScript provides types for getStaticProps
and getStaticPaths
:
// pages/posts/[id].tsx
import type { NextPage, GetStaticProps, GetStaticPaths } from 'next';
interface PostPageProps {
post: {
id: number;
title: string;
content: string;
};
}
const PostPage: NextPage<PostPageProps> = ({ post }) => {
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
</div>
);
};
export const getStaticPaths: GetStaticPaths = async () => {
// In a real app, you might fetch this from an API
const posts = [
{ id: 1 },
{ id: 2 },
{ id: 3 },
];
const paths = posts.map((post) => ({
params: { id: post.id.toString() },
}));
return { paths, fallback: false };
};
export const getStaticProps: GetStaticProps<PostPageProps> = async ({ params }) => {
// In a real app, fetch the post from an API or database
const post = {
id: Number(params?.id),
title: `Post ${params?.id}`,
content: `This is the content for post ${params?.id}`,
};
return {
props: {
post,
},
};
};
export default PostPage;
API Routes
TypeScript also works with Next.js API routes:
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next';
interface ResponseData {
message: string;
timestamp: number;
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
res.status(200).json({
message: 'Hello from TypeScript API route!',
timestamp: Date.now()
});
}
Custom Type Definitions
Global Types
You can define global types for your application in a declaration file:
// types/global.d.ts
export interface User {
id: number;
name: string;
email: string;
}
export interface Product {
id: number;
title: string;
price: number;
description: string;
}
Then import them where needed:
// components/UserProfile.tsx
import { FC } from 'react';
import { User } from '../types/global';
interface UserProfileProps {
user: User;
}
const UserProfile: FC<UserProfileProps> = ({ user }) => {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
};
export default UserProfile;
Environment Variables
For type-safe environment variables, create a module declaration:
// env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'development' | 'production' | 'test';
NEXT_PUBLIC_API_URL: string;
DATABASE_URL: string;
}
}
Practical Example: Building a Typed Todo Application
Let's build a simple Todo application with TypeScript and Next.js:
Define Types
// types/todo.ts
export interface Todo {
id: number;
text: string;
completed: boolean;
}
export type TodoState = {
todos: Todo[];
loading: boolean;
};
export type TodoAction =
| { type: 'ADD_TODO'; payload: Omit<Todo, 'id'> }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'DELETE_TODO'; payload: number }
| { type: 'SET_TODOS'; payload: Todo[] }
| { type: 'SET_LOADING'; payload: boolean };
Create a Reducer
// reducers/todoReducer.ts
import { TodoState, TodoAction, Todo } from '../types/todo';
export const initialState: TodoState = {
todos: [],
loading: false,
};
let nextId = 1;
export function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, { ...action.payload, id: nextId++ }],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
),
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload),
};
case 'SET_TODOS':
return {
...state,
todos: action.payload,
};
case 'SET_LOADING':
return {
...state,
loading: action.payload,
};
default:
return state;
}
}
Create Todo Components
// components/TodoItem.tsx
import { FC } from 'react';
import { Todo } from '../types/todo';
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
const TodoItem: FC<TodoItemProps> = ({ todo, onToggle, onDelete }) => {
return (
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '10px'
}}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span
style={{
marginLeft: '10px',
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.text}
</span>
<button
onClick={() => onDelete(todo.id)}
style={{ marginLeft: 'auto' }}
>
Delete
</button>
</div>
);
};
export default TodoItem;
// components/TodoList.tsx
import { FC } from 'react';
import TodoItem from './TodoItem';
import { Todo } from '../types/todo';
interface TodoListProps {
todos: Todo[];
onToggleTodo: (id: number) => void;
onDeleteTodo: (id: number) => void;
}
const TodoList: FC<TodoListProps> = ({ todos, onToggleTodo, onDeleteTodo }) => {
if (todos.length === 0) {
return <p>No todos yet. Add one!</p>;
}
return (
<div>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggleTodo}
onDelete={onDeleteTodo}
/>
))}
</div>
);
};
export default TodoList;
Main Page with TypeScript
// pages/index.tsx
import { NextPage } from 'next';
import { useReducer, useState, FormEvent } from 'react';
import TodoList from '../components/TodoList';
import { todoReducer, initialState } from '../reducers/todoReducer';
const Home: NextPage = () => {
const [state, dispatch] = useReducer(todoReducer, initialState);
const [newTodo, setNewTodo] = useState('');
const handleAddTodo = (e: FormEvent) => {
e.preventDefault();
if (newTodo.trim()) {
dispatch({
type: 'ADD_TODO',
payload: { text: newTodo.trim(), completed: false },
});
setNewTodo('');
}
};
const handleToggleTodo = (id: number) => {
dispatch({ type: 'TOGGLE_TODO', payload: id });
};
const handleDeleteTodo = (id: number) => {
dispatch({ type: 'DELETE_TODO', payload: id });
};
return (
<div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
<h1>TypeScript Next.js Todo App</h1>
<form onSubmit={handleAddTodo} style={{ marginBottom: '20px' }}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="What needs to be done?"
style={{ padding: '8px', width: '70%' }}
/>
<button
type="submit"
style={{ padding: '8px 16px', marginLeft: '10px' }}
>
Add
</button>
</form>
<TodoList
todos={state.todos}
onToggleTodo={handleToggleTodo}
onDeleteTodo={handleDeleteTodo}
/>
<div style={{ marginTop: '20px' }}>
<p>Total: {state.todos.length} items</p>
<p>
Completed: {state.todos.filter((todo) => todo.completed).length} items
</p>
</div>
</div>
);
};
export default Home;
Type Checking and Linting
To enhance the TypeScript experience in Next.js, add ESLint with TypeScript support:
npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-react
Create an .eslintrc.js
file:
// .eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'next/core-web-vitals',
'plugin:@typescript-eslint/recommended',
],
rules: {
// Add custom rules here
},
};
Project Architecture Visualization
Here's a diagram showing a typical Next.js with TypeScript project structure:
Advanced TypeScript Features in Next.js
Custom App with TypeScript
Create a typed custom _app.tsx
:
// pages/_app.tsx
import type { AppProps } from 'next/app';
import '../styles/globals.css';
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
export default MyApp;
Custom Document with TypeScript
Create a typed custom _document.tsx
:
// pages/_document.tsx
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document';
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps };
}
render() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
Custom Hooks with TypeScript
Create type-safe custom hooks:
// hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
// State to store our value
const [storedValue, setStoredValue] = useState<T>(initialValue);
useEffect(() => {
// Get from local storage by key
if (typeof window !== 'undefined') {
try {
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
setStoredValue(item ? JSON.parse(item) : initialValue);
} catch (error) {
console.log(error);
setStoredValue(initialValue);
}
}
}, [key, initialValue]);
// Return a wrapped version of useState's setter function that
// persists the new value to localStorage
const setValue = (value: T) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
}
Usage:
// components/ThemeToggle.tsx
import { FC } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
type Theme = 'light' | 'dark';
const ThemeToggle: FC = () => {
const [theme, setTheme] = useLocalStorage<Theme>('theme', 'light');
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<button onClick={toggleTheme}>
Current theme: {theme}. Click to toggle.
</button>
);
};
export default ThemeToggle;
Summary
In this guide, we've explored how to use TypeScript with Next.js to build type-safe web applications. We covered:
- Setting up a Next.js project with TypeScript
- Using TypeScript with Next.js pages, API routes, and data fetching methods
- Creating custom type definitions for your application
- Building a practical Todo application with typed components and state
- Setting up proper linting and type checking
- Using advanced TypeScript features with Next.js
TypeScript enhances the Next.js development experience by providing type safety, better tooling, and clearer code structure. By combining these technologies, you can build robust, maintainable web applications with confidence.
Additional Resources
Exercises
- Create a simple blog with TypeScript and Next.js that fetches posts from a mock API
- Add strong typing to an existing Next.js project
- Create a user authentication system with TypeScript interfaces for user types
- Build a shopping cart with TypeScript for product and cart item types
- Implement form validation using TypeScript for type-checking user inputs
By completing these exercises, you'll gain practical experience with TypeScript and Next.js, solidifying your understanding of these powerful technologies.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)