Most Next.js projects say TypeScript and don't mean it. The tsconfig.json has 'strict: true' but the code is full of type assertions, implicit any fallbacks, and unguarded array index accesses that crash at runtime. Proper TypeScript in a Next.js project is a configuration choice first - then a set of patterns. Here's what the configuration actually enables and which patterns prevent the bugs that TypeScript is specifically designed to catch.
1. The tsconfig.json that actually matters
'strict: true' enables eight compiler options at once but misses a few that eliminate entire categories of runtime errors. These are the additional flags worth enabling:
// tsconfig.json
{
"compilerOptions": {
"strict": true,
// strict enables: strictNullChecks, strictFunctionTypes,
// strictBindCallApply, noImplicitAny, noImplicitThis, alwaysStrict
// Add these on top of strict:
"noUncheckedIndexedAccess": true,
// arr[0] is T | undefined, not T.
// Catches: "cannot read property of undefined" at compile time.
"noImplicitOverride": true,
// Subclass methods that override parent must use the override keyword.
// Catches: silent overrides that break when the parent method changes.
"exactOptionalPropertyTypes": true,
// { x?: string } is not the same as { x: string | undefined }.
// Catches: assigning undefined to optional props in strict APIs.
"forceConsistentCasingInFileNames": true,
// Prevents import path casing bugs that only show up on Linux CI.
"noPropertyAccessFromIndexSignature": true,
// obj.unknownKey must be obj["unknownKey"] for indexed types.
// Makes dynamic property access visually distinct and intentional.
}
}2. noUncheckedIndexedAccess - the flag most teams skip
This is the single most impactful flag not included in strict mode. Without it, TypeScript treats array[0] as T even though array might be empty. The result is a class of crashes that TypeScript should catch but doesn't by default.
// Without noUncheckedIndexedAccess:
const items: string[] = [];
const first = items[0]; // TypeScript says: string
first.toUpperCase(); // Runtime crash: cannot read property of undefined
// With noUncheckedIndexedAccess:
const items: string[] = [];
const first = items[0]; // TypeScript says: string | undefined ✓
first?.toUpperCase(); // must handle undefined case
// Same applies to object index signatures:
const map: Record<string, number> = {};
const val = map["key"]; // number | undefined (with the flag)
if (val !== undefined) {
console.log(val * 2); // number - safe
}3. Type-safe Server Actions
Server Actions are the App Router pattern for form submissions and mutations. The type safety trap: the action receives FormData which is untyped. Parse and validate it at the top of every action rather than trusting the input.
// ❌ Untyped - any value can come through, crashes silently
async function createPost(formData: FormData) {
"use server";
const title = formData.get("title") as string; // type assertion bypasses safety
await db.posts.create({ title });
}
// ✅ Validated with zod - unknown input becomes typed at runtime
import { z } from "zod";
const PostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
});
async function createPost(formData: FormData) {
"use server";
const result = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
await db.posts.create(result.data); // result.data is fully typed
}4. Type-safe fetch with proper error handling
fetch() returns Promise<Response> and the response.json() method returns Promise<any>. Without explicit typing, your API responses are untyped all the way through the component tree. The fix is a typed fetch wrapper that validates the response shape.
// lib/fetch.ts - typed fetch wrapper
async function fetchTyped<T>(
url: string,
schema: z.ZodType<T>,
options?: RequestInit
): Promise<T> {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${url}`);
}
const data = await res.json();
return schema.parse(data); // throws if response doesn't match schema
}
// Usage - result is fully typed, no type assertions needed
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
const user = await fetchTyped("/api/user/123", UserSchema);
// user: { id: string; name: string; email: string }5. Avoid type assertions - they disable the type checker
Type assertions (value as SomeType) tell TypeScript to stop checking. They're often used as a quick fix when the types don't line up, but they shift a compile-time error into a runtime crash with no diagnostic. The patterns to replace them with:
// ❌ Type assertion - bypasses checking
const user = data as User;
// ✅ Type guard - checks at runtime
function isUser(data: unknown): data is User {
return (
typeof data === "object" &&
data !== null &&
"id" in data &&
"email" in data
);
}
if (isUser(data)) {
console.log(data.email); // typed correctly inside the if block
}
// ❌ Asserting environment variables exist
const apiKey = process.env.API_KEY as string;
// ✅ Validate at startup
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) throw new Error(`Missing env var: ${key}`);
return value;
}
const apiKey = requireEnv("API_KEY");6. Component prop types - discriminated unions over optional props
Optional props that only make sense together are a source of invalid state. If a component can be in two distinct modes, a discriminated union makes invalid combinations unrepresentable at compile time.
// ❌ Optional props allow invalid combinations
interface ButtonProps {
variant?: "default" | "loading" | "error";
loadingText?: string; // only makes sense when variant === "loading"
errorMessage?: string; // only makes sense when variant === "error"
}
// Nothing stops variant="default" with errorMessage="something"
// ✅ Discriminated union - invalid state is unrepresentable
type ButtonProps =
| { variant: "default"; label: string }
| { variant: "loading"; loadingText: string }
| { variant: "error"; errorMessage: string; onRetry: () => void };
function Button(props: ButtonProps) {
if (props.variant === "loading") {
return <Spinner text={props.loadingText} />; // loadingText is string, not string | undefined
}
if (props.variant === "error") {
return <ErrorState message={props.errorMessage} onRetry={props.onRetry} />;
}
return <button>{props.label}</button>;
}7. Type-safe route params with a utility
In Next.js 15+, params are Promise<...>. A small utility keeps the pattern DRY and adds consistent error handling for missing params:
// lib/params.ts
export async function requireParam<T extends Record<string, string>>(
params: Promise<T>,
key: keyof T
): Promise<string> {
const resolved = await params;
const value = resolved[key];
if (!value) throw new Error(`Missing route param: ${String(key)}`);
return value;
}
// Usage
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const slug = await requireParam(params, "slug");
const post = await getPost(slug);
if (!post) notFound();
return <BlogPost post={post} />;
}8. Common TypeScript mistakes in Next.js projects
| Mistake | Correct pattern |
|---|---|
| response.json() returns any | Use a typed fetch wrapper with zod validation |
| formData.get() as string | Parse FormData with zod in Server Actions |
| arr[0] treated as T | Enable noUncheckedIndexedAccess in tsconfig |
| value as SomeType everywhere | Write type guards or use zod.parse() |
| Optional props for related data | Discriminated unions for mutually exclusive states |
| process.env.KEY as string | requireEnv() utility that throws on missing value |
| any in third-party types | Wrap in a typed adapter at the integration boundary |
Every TheKitBase template ships with strict TypeScript configured out of the box - noUncheckedIndexedAccess, noImplicitOverride, and exactOptionalPropertyTypes all enabled. The TypeScript setup alone saves hours of debugging on a new project.
Next.js templates with TypeScript strict mode pre-configured - SaaS dashboards, CRM, AI landing pages, portfolios. From $39, one-time purchase.
Browse Templates