DEV Community

Cover image for TypeScript Best Practices for Next.js Projects in 2026
TheKitBase
TheKitBase

Posted on • Originally published at thekitbase.app

TypeScript Best Practices for Next.js Projects in 2026

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 is a configuration choice first - then a set of patterns.

1. The tsconfig.json that actually matters

strict: true enables eight compiler options but misses several that eliminate entire categories of runtime errors:

{
  "compilerOptions": {
    "strict": true,

    "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.

    "exactOptionalPropertyTypes": true,
    // { x?: string } is not the same as { x: string | undefined }.

    "forceConsistentCasingInFileNames": true,
    // Prevents import path casing bugs that only show on Linux CI.

    "noPropertyAccessFromIndexSignature": true
    // obj.unknownKey must be obj["unknownKey"] for indexed types.
  }
}
Enter fullscreen mode Exit fullscreen mode

2. noUncheckedIndexedAccess - the flag most teams skip

The most impactful flag not included in strict. Without it, array[0] is typed as T even when the array might be empty:

// Without noUncheckedIndexedAccess:
const items: string[] = [];
const first = items[0];   // TypeScript says: string
first.toUpperCase();       // Runtime crash

// With noUncheckedIndexedAccess:
const first = items[0];   // TypeScript says: string | undefined ✓
first?.toUpperCase();      // Must handle undefined
Enter fullscreen mode Exit fullscreen mode

Same for 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
}
Enter fullscreen mode Exit fullscreen mode

3. Type-safe Server Actions

Server Actions receive FormData which is untyped. Parse and validate at the top of every action:

// ❌ Type assertion bypasses safety
async function createPost(formData: FormData) {
  "use server";
  const title = formData.get("title") as string;
  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
}
Enter fullscreen mode Exit fullscreen mode

4. Type-safe fetch

response.json() returns Promise<any>. Use a typed 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}`);
  return schema.parse(await res.json()); // throws if shape doesn't match
}

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 }
Enter fullscreen mode Exit fullscreen mode

5. Replace type assertions with type guards

Type assertions (value as SomeType) tell the type checker to stop checking:

// ❌ Type assertion - crash becomes a runtime problem
const user = data as User;

// ✅ Type guard - check at runtime
function isUser(data: unknown): data is User {
  return (
    typeof data === "object" &&
    data !== null &&
    "id" in data &&
    "email" in data
  );
}

// ❌ Asserting env vars 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");
Enter fullscreen mode Exit fullscreen mode

6. Discriminated unions over optional props

Optional props that only make sense together allow invalid state. Discriminated unions make it unrepresentable:

// ❌ Optional props allow invalid combinations
interface ButtonProps {
  variant?: "default" | "loading" | "error";
  loadingText?: string;
  errorMessage?: string;
}

// ✅ 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
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Common mistakes and their fixes

Mistake Correct pattern
response.json() returns any Typed fetch wrapper with zod
formData.get() as string Parse FormData with zod in Server Actions
arr[0] treated as T Enable noUncheckedIndexedAccess
value as SomeType everywhere Write type guards or use zod.parse()
Optional props for related data Discriminated unions
process.env.KEY as string requireEnv() utility
any in third-party types Typed adapter at the integration boundary

Originally published at thekitbase.app

Top comments (0)