Skip to main content

How to Set Up TypeScript with Every Major 2026

·PkgPulse Team
0

TL;DR

Every major framework ships TypeScript support out of the box in 2026. Running npm create vite@latest or npx create-next-app generates a working tsconfig.json. The important customization is in strict settings, moduleResolution, and framework-specific type declarations. This guide covers the right tsconfig for each major framework and the gotchas that routinely cost hours.

The Universal Base: tsconfig Settings That Apply Everywhere

Before framework-specific details, there's a set of tsconfig options that should be enabled in every project in 2026. These aren't optional — they catch real bugs that cost real time.

{
  "compilerOptions": {
    "strict": true,                    // Enables all strict checks — do this first
    "noUncheckedIndexedAccess": true,  // arr[0] can be undefined — TypeScript will tell you
    "skipLibCheck": true,              // Skip type-checking node_modules — fast, safe
    "resolveJsonModule": true,         // import data from './data.json'
    "isolatedModules": true,           // Each file must be independently compilable
    "verbatimModuleSyntax": true       // TypeScript 5.0+ — correct ESM/CJS import types
  }
}

The two most commonly missed options are noUncheckedIndexedAccess and verbatimModuleSyntax. The first ensures arr[0] is typed as T | undefined, not just T — this catches index-out-of-bounds bugs at the type level. The second ensures you use import type for type-only imports, which prevents subtle bundling issues when types are erased.

The community @tsconfig/bases packages provide maintained starting points for different runtimes:

npm install -D @tsconfig/strictest    # Most strict — good baseline for any project
npm install -D @tsconfig/node20       # Node.js 20-specific optimizations

Next.js

Next.js has the most polished TypeScript integration of any framework. Running create-next-app --typescript generates a complete tsconfig.json and a next-env.d.ts file that injects Next.js-specific types. You should not delete or edit next-env.d.ts — it's regenerated on every build.

{
  "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"]
}

The "plugins": [{ "name": "next" }] line adds the Next.js TypeScript language server plugin. This plugin provides editor completions for App Router conventions — it tells you when generateStaticParams has the wrong return type, when metadata exports don't match the Metadata interface, and when Server/Client Component boundaries are violated.

The App Router introduces type patterns worth knowing:

// Page component props — params and searchParams
interface PageProps {
  params: { slug: string };
  searchParams: { [key: string]: string | string[] | undefined };
}

export default function Page({ params, searchParams }: PageProps) {
  // TypeScript knows params.slug is a string
}

// Route Handler (app/api/route.ts)
import type { NextRequest } from 'next/server';

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  return Response.json({ id: params.id });
}

// Metadata export
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'My Page',
  description: 'Page description',
  openGraph: {
    images: [{ url: '/og.png', width: 1200, height: 630 }],
  },
};

Key gotcha: noEmit: true means TypeScript only type-checks, never compiles — Next.js's webpack/Turbopack pipeline handles actual transpilation. Don't change this. Running tsc directly will produce no output files, which surprises developers expecting a dist folder.


Remix

Remix uses Vite as its build tool since v2, which changes the tsconfig requirements. Vite uses its own module resolution that differs from Node.js's, and the tsconfig must reflect this.

{
  "include": ["**/*.ts", "**/*.tsx", "**/.server/**/*.ts", "**/.server/**/*.tsx"],
  "compilerOptions": {
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
    "types": ["@remix-run/node", "vite/client"],
    "isolatedModules": true,
    "esModuleInterop": true,
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "resolveJsonModule": true,
    "target": "ES2022",
    "strict": true,
    "allowJs": true,
    "skipLibCheck": true,
    "noEmit": true,
    "paths": {}
  }
}

Note "moduleResolution": "Bundler" (capital B). Remix's Vite setup uses bundler resolution, meaning TypeScript won't enforce Node.js's .js extension requirements on imports. This is correct for Remix — don't change it to "node16" or "nodenext", which will break imports.

// Remix type patterns — loader and action typing
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

type LoaderData = { user: { id: string; name: string } };

export async function loader({ params }: LoaderFunctionArgs) {
  const user = await getUser(params.id!);
  return json<LoaderData>({ user });
}

export default function UserPage() {
  const { user } = useLoaderData<typeof loader>();
  // user is correctly typed as { id: string; name: string }
  return <div>{user.name}</div>;
}

Key gotcha: Remix's useLoaderData<typeof loader>() pattern infers types from the loader return value. This only works correctly when the loader uses json() from @remix-run/node and you pass the loader function as the type parameter — not a manually typed interface.


SvelteKit

SvelteKit's TypeScript setup is the most "managed" of any framework. SvelteKit generates a .svelte-kit/tsconfig.json automatically, and your project's tsconfig.json extends it. The generated file is maintained by SvelteKit and updated when you run vite dev or vite build.

// tsconfig.json — your file (minimal, extends the generated one)
{
  "extends": "./.svelte-kit/tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "verbatimModuleSyntax": true
  }
}

// .svelte-kit/tsconfig.json — generated, do not edit
// Contains paths, includes, and SvelteKit-specific compiler options

SvelteKit's most important TypeScript feature is the app.d.ts ambient declarations file:

// src/app.d.ts — ambient type declarations for SvelteKit
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;  // Available in hooks and load functions
    }

    interface PageData {
      // Common data available on all pages
    }

    interface Error {
      message: string;
      code?: string;  // Custom error shape
    }
  }
}

export {};

After defining these, SvelteKit's load functions and hooks are fully typed:

// +page.server.ts — typed via app.d.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals, params }) => {
  // locals.user is typed as { id: string; email: string } | null
  if (!locals.user) throw redirect(302, '/login');

  return { item: await getItem(params.id) };
};

Run npx svelte-check --tsconfig ./tsconfig.json in CI to catch type errors in .svelte files — the standard tsc command doesn't check .svelte file types.


Astro

Astro's TypeScript setup is similar to SvelteKit — the framework generates type declarations, and you extend a managed config. The key difference is Astro's src/env.d.ts file, which imports the framework's type definitions.

// src/env.d.ts — required for Astro types
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
// tsconfig.json
{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"]
    }
  }
}

Astro ships three preset configs: astro/tsconfigs/base, astro/tsconfigs/strict, and astro/tsconfigs/strictest. Start with strict — it's the recommended default.

// Astro component typing
---
// The frontmatter script is TypeScript
interface Props {
  title: string;
  description?: string;
  tags: string[];
}

const { title, description = '', tags } = Astro.props;
// TypeScript infers: title is string, description is string, tags is string[]
---

<article>
  <h1>{title}</h1>
  {description && <p>{description}</p>}
</article>

Run npx astro check in CI for .astro file type checking. Like SvelteKit, standard tsc doesn't check the framework-specific file extensions.


Hono (Edge and Node.js)

Hono is a TypeScript-first framework designed to run on Cloudflare Workers, Deno, Bun, and Node.js. Its tsconfig differs by target runtime.

// tsconfig.json — Hono on Cloudflare Workers
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "moduleResolution": "bundler",
    "strict": true,
    "lib": ["ES2020"],
    "types": ["@cloudflare/workers-types"],
    "isolatedModules": true,
    "resolveJsonModule": true,
    "noEmit": true
  },
  "include": ["src", "worker-configuration.d.ts"]
}
// tsconfig.json — Hono on Node.js
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "lib": ["ES2022"],
    "types": ["node"],
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src"]
}

Hono's generic request context typing is one of its best features — you can type route handlers end-to-end:

import { Hono } from 'hono';

type Variables = {
  userId: string;
  user: { id: string; email: string };
};

const app = new Hono<{ Variables: Variables }>();

app.use('/protected/*', async (c, next) => {
  const token = c.req.header('Authorization');
  const user = await verifyToken(token);
  c.set('user', user);      // TypeScript knows the shape
  await next();
});

app.get('/protected/profile', (c) => {
  const user = c.get('user');  // Typed as { id: string; email: string }
  return c.json({ user });
});

Fastify

Fastify is TypeScript-first and its type system is particularly rich — request body, params, query string, response, and headers can all be typed per route.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
// Typed route with Fastify's generic system
import Fastify from 'fastify';

const fastify = Fastify({ logger: true });

interface CreateUserBody {
  name: string;
  email: string;
}

interface UserParams {
  id: string;
}

fastify.post<{ Body: CreateUserBody }>('/users', {
  schema: {
    body: {
      type: 'object',
      required: ['name', 'email'],
      properties: {
        name: { type: 'string' },
        email: { type: 'string', format: 'email' },
      },
    },
  },
}, async (request, reply) => {
  const { name, email } = request.body; // Typed as CreateUserBody
  return { user: await createUser({ name, email }) };
});
// package.json scripts for Fastify TypeScript
{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "typecheck": "tsc --noEmit"
  }
}

Common TypeScript Configuration Issues and How to Fix Them

These are the five configuration problems that cause the most wasted hours in real TypeScript projects. Each one has a specific fix, and understanding why the fix works prevents you from hitting it again.

1. moduleResolution conflicts — Bundler vs NodeNext vs Node10

The most common tsconfig error in 2026 is using the wrong moduleResolution for your runtime environment. TypeScript 5.0 introduced "Bundler" mode alongside the existing "NodeNext" and legacy "Node10" (previously called "Node"). These are not interchangeable.

"NodeNext" enforces Node.js ESM rules: relative imports must include the .js extension, even when writing .ts files. This is correct for Node.js servers that use native ESM ("type": "module" in package.json), but it breaks Vite, Next.js, and Remix projects because those bundlers resolve extensions themselves.

"Bundler" mode is designed for projects processed by a bundler: it allows bare imports without extensions, follows exports and imports fields in package.json, but does not enforce Node.js's specific module loading algorithm. Use "Bundler" for any project that goes through Vite, webpack, Turbopack, or esbuild. Use "NodeNext" for Node.js servers that emit and run JavaScript directly.

The symptom of using "NodeNext" in a Vite project: TypeScript errors like "An import path cannot end with a '.ts' extension" or "Relative import paths need explicit file extensions".

2. Path alias setup that breaks at runtime

TypeScript's paths configuration maps import aliases for the TypeScript compiler but does not affect the runtime. If you write import { db } from '@/db' and configure "paths": { "@/*": ["./src/*"] } in tsconfig, TypeScript will be happy — but Node.js will throw Cannot find module '@/db' at runtime unless you also configure your bundler.

For Next.js, the paths in tsconfig are automatically picked up by webpack/Turbopack — no extra configuration needed. For Vite projects, you need to add the alias to vite.config.ts as well:

// vite.config.ts — must mirror tsconfig paths
import { resolve } from 'path';

export default {
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),
    },
  },
};

For Node.js servers that compile with tsc and run the output directly, you need tsconfig-paths or similar at runtime, or use tsc-alias as a post-compilation step to rewrite import paths in the output.

3. skipLibCheck tradeoffs

skipLibCheck: true tells TypeScript to skip type-checking declaration files (.d.ts files) in node_modules. The tradeoff: you get faster type checking, but type errors in third-party packages are silently ignored. In practice, skipLibCheck: true is the right choice for almost all application code — third-party type errors are usually conflicts between two packages' type definitions, not bugs in your code, and fixing them requires upstream changes you cannot make.

The one case where you might want skipLibCheck: false: library authors who publish their own type declarations. If your package ships .d.ts files, you want to verify those are internally consistent.

4. The strict: true vs individual flags debate

"strict": true is a shorthand that enables a bundle of individual checks: strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitAny, noImplicitThis, and alwaysStrict. Enabling strict: true is almost always the right approach for new projects.

The individual flags debate arises when onboarding TypeScript into an existing JavaScript codebase. Enabling all of strict: true at once on a large existing codebase produces thousands of errors. The pragmatic approach: enable noImplicitAny and strictNullChecks first, fix those errors, then enable the remaining strict flags. These two flags catch the most serious bugs. The remaining flags (strictFunctionTypes, strictBindCallApply, etc.) are valuable but less likely to surface real runtime bugs in application code.

5. Declaration file conflicts

The symptom: TypeScript errors like "Duplicate identifier 'X'" or "Cannot redeclare block-scoped variable" in files you did not write. This usually means two packages are declaring the same global type, or a types field in tsconfig is including global type definitions that conflict.

The fix: check your "types" array in compilerOptions. If you have "types": ["node", "@cloudflare/workers-types"] simultaneously, you will get conflicts because both declare fetch, Request, Response, and other globals differently. Include only the types appropriate for your runtime. For projects that genuinely target multiple runtimes, split into multiple tsconfig files using project references.


TypeScript Performance Optimization for Large Codebases

tsc slows down in large codebases, and the slowdown is not linear — a codebase with 500 files can easily take 30 seconds for a type check that took 2 seconds at 100 files. Here are the techniques that make a measurable difference.

--incremental mode is the first and easiest optimization. Adding "incremental": true to your compilerOptions causes TypeScript to write a .tsbuildinfo file that stores information about the compilation. On subsequent runs, TypeScript only re-checks files that changed and their dependents. The first run is the same speed, but subsequent runs can be 5-10x faster. This is already enabled by default in Next.js's generated tsconfig. For other projects, add it explicitly and ensure .tsbuildinfo is gitignored but preserved in CI caches between runs.

Project references are the industrial-strength solution for monorepos and large applications with distinct subsystems. Instead of one tsconfig that covers the entire codebase, you split into multiple tsconfig files, each covering a subsystem, with explicit dependency declarations between them:

// tsconfig.json — root
{
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/api" },
    { "path": "./packages/web" }
  ]
}

// packages/api/tsconfig.json
{
  "compilerOptions": {
    "composite": true,     // Required for project references
    "outDir": "dist",
    "rootDir": "src"
  },
  "references": [
    { "path": "../shared" }  // api depends on shared
  ]
}

With project references and tsc --build (or tsc -b), TypeScript only rebuilds packages whose source files or dependencies changed. In a monorepo where the web package depends on the api package, changing a web-only file does not trigger a recheck of the api package.

include and exclude tuning is frequently overlooked. The default "include": ["**/*"] includes everything in the project root, including test fixtures, generated files, and documentation that does not need type checking in every run. Be explicit:

{
  "include": ["src/**/*.ts", "src/**/*.tsx"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

For CI, consider running type checking separately from tests and excluding test files from the main tsconfig. Create a separate tsconfig.test.json that extends the main config and adds test files back in — this way your production type check is fast, and your test type check runs independently.

skipLibCheck: true reduces check time by skipping type declaration files in node_modules. This is the easiest single-flag win and is safe for application code. Pairing it with the --noEmit flag in CI (type-check only, no output) keeps the check focused on finding type errors rather than producing artifacts.

For teams where tsc is the bottleneck in CI, the check time for TypeScript tooling options like ts-node vs tsx vs tsc directly is worth reviewing — see tsx vs ts-node vs Bun in 2026 for a direct comparison of TypeScript execution speed across tools.


Type-Only Imports and the verbatimModuleSyntax Flag

The import type syntax and the verbatimModuleSyntax compiler flag are TypeScript features that are simple to understand but significantly impact bundle output and bundler performance. Many codebases are not using them correctly in 2026.

When you write import { User } from './types', TypeScript knows User is a type and will erase it from the compiled output. However, the import statement itself may still appear in the output depending on your module system, causing bundlers to potentially include the module in the bundle even though nothing from it is used at runtime. When you write import type { User } from './types', TypeScript and bundlers can guarantee at parse time that this import will produce no runtime code and can be safely tree-shaken.

verbatimModuleSyntax, introduced in TypeScript 5.0, enforces this discipline automatically. With the flag enabled, TypeScript requires that any import used only as a type must use import type. If you write import { User } from './types' and use User only in type positions, TypeScript will error and tell you to use import type { User } from './types' instead. This is the flag's main value: it makes the "erase at compile time" behavior explicit and verifiable rather than inferred.

Migrating an existing codebase to use verbatimModuleSyntax typically produces a wave of errors on first enabling it. The errors are all the same pattern: import { SomeType } where SomeType is only used in type positions. The fix is mechanical:

// Before
import { User, createUser } from './users';  // User is type-only, createUser is runtime

// After
import type { User } from './users';
import { createUser } from './users';

Most editors with TypeScript language server support will automatically suggest or apply this fix. Running tsc --noEmit with verbatimModuleSyntax: true after enabling the flag will surface every import that needs to be updated. In codebases with good test coverage, enabling this flag, running the auto-fix, and re-running tests is usually a safe one-step migration.

The bundler performance impact is real but secondary to correctness. By using import type consistently, bundlers like Vite and webpack can skip processing modules that are only referenced as types during development server startup and HMR updates. In large TypeScript codebases, this translates to faster cold starts during development.

For the full picture on TypeScript tooling in 2026, including how verbatimModuleSyntax interacts with different build tools, see the state of TypeScript tooling in 2026.


Common tsconfig Mistakes

These mistakes come up repeatedly in real codebases and each one creates real problems.

// Mistake 1: Wrong moduleResolution for bundler-based projects
"moduleResolution": "node"
// Fix: use "bundler" for Vite, Next.js, Remix; use "NodeNext" for Node.js

// Mistake 2: Not enabling strict
"strict": false
// This allows implicit any, skips null checks — bugs guaranteed in production

// Mistake 3: Missing noUncheckedIndexedAccess
// Without it: users[0].name — crashes if users is empty, TypeScript won't warn you
"noUncheckedIndexedAccess": true

// Mistake 4: target too old for the runtime
"target": "ES5"
// Produces verbose polyfilled output for runtimes that support ES2020+
// Use ES2020+ for Node.js 16+, Cloudflare Workers, modern browsers

// Mistake 5: Not separating type checking from building in CI
// Wrong:
"build": "tsc && next build"
// Right:
"typecheck": "tsc --noEmit",
"build": "next build"
// Type checking and building are independent — run them in parallel

// Mistake 6: verbatimModuleSyntax missing
// Without it, TypeScript allows: import { User } from './types'
// When User is a type, this causes runtime errors in strict ESM
"verbatimModuleSyntax": true
// Enforces: import type { User } from './types'

For any new TypeScript project in 2026, regardless of framework:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "isolatedModules": true
  }
}

Add the framework-specific settings on top of this baseline. The moduleResolution depends on your runtime: "bundler" for Vite/Next.js/Remix, "NodeNext" for Node.js APIs. The target depends on your deployment environment: ES2020 for broad compatibility, ES2022 for modern Node.js.

Check full package health and download trends for TypeScript and related packages on PkgPulse. For a framework comparison, see Next.js vs Remix on PkgPulse. See also the new wave of TypeScript-first libraries in 2026 for how TypeScript-native design is reshaping the npm ecosystem.

See also: Fastify vs Hono and Next.js vs Remix

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.