Launch pricing - early-adopter rates, prices increase June 9th. Shop now →

Development8 min readby Minnie

TypeScript Best Practices for Next.js Projects in 2026

The TypeScript configuration and patterns that actually catch bugs in Next.js projects - strict mode explained, noUncheckedIndexedAccess, type-safe server actions, and the mistakes most teams repeat.

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

MistakeCorrect pattern
response.json() returns anyUse a typed fetch wrapper with zod validation
formData.get() as stringParse FormData with zod in Server Actions
arr[0] treated as TEnable noUncheckedIndexedAccess in tsconfig
value as SomeType everywhereWrite type guards or use zod.parse()
Optional props for related dataDiscriminated unions for mutually exclusive states
process.env.KEY as stringrequireEnv() utility that throws on missing value
any in third-party typesWrap 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