· Zerdalu
Go Back

Splitting TypeScript Config in TanStack Start

When building full-stack applications with frameworks like TanStack Start, we often find ourselves in a “type identity crisis.”

On one hand, our client-side code needs access to the DOM (window, document, localStorage). On the other, our server functions—running on Cloudflare Workers—need access to Worker-specific APIs (KV, D1, and the Cache API).

The problem? If you put everything in one tsconfig.json, TypeScript often gets confused, or worse, you accidentally use a browser API on the server, and your code crashes at runtime despite passing the type check.

Recently, while implementing the Cloudflare Cache API to optimize authentication checks, I hit a wall of configuration errors. Here is how I solved them.


The Problem: The “Everything” Config

Initially, my project had a single tsconfig.json. As soon as I tried to use caches.default inside a .server.ts file, I faced three main issues:

  1. Type Clashes: The global types for the browser and Cloudflare Workers often overlap or conflict.
  2. Path Alias Loss: When splitting configs to solve the type clash, my path aliases (like #/utils/*) stopped working in server files.
  3. ESLint Meltdown: ESLint started throwing parsing errors, claiming that server files were not included in the project’s TSConfig.

To fix this, I needed a Solution-Style TSConfig architecture.


The Solution: A Layered Configuration

The goal was to create a strict boundary between the client and the server while sharing common settings. I moved the configuration into a dedicated /tsconfig folder to keep the root directory clean.

1. The Base Layer (tsconfig.base.json)

First, I created a base configuration. This is the “single source of truth” for shared settings like strictness, target version, and—most importantly—path aliases.

// tsconfig/tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "moduleResolution": "bundler",
    "paths": {
      "#/*": ["../src/*"],
      "@/*": ["../src/*"]
    },
    "strict": true,
    "noEmit": true
    // ... other shared settings
  }
}

2. The Specialization Layer

Next, I created two separate configs that extend the base.

The Server Config: This includes only the server files and the Cloudflare type definitions. I used our local worker-configuration.d.ts to provide the global caches object and environment variables.

// tsconfig/tsconfig.server.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "lib": ["ES2022"],
    "types": []
  },
  "include": [
    "../**/*.server.ts",
    "../**/*.server.tsx",
    "../worker-configuration.d.ts"
  ]
}

The Client Config: This includes the DOM libraries and excludes all .server.ts files to prevent accidental leaks.

// tsconfig/tsconfig.client.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["vite/client"]
  },
  "include": [
    "../**/*.ts",
    "../**/*.tsx",
    "../eslint.config.js",
    "../prettier.config.js",
    "../vite.config.js"
  ],
  "exclude": ["../**/*.server.ts", "../**/*.server.tsx"]
}

3. The Solution Layer (tsconfig.json at root)

Finally, I kept a tsconfig.json at the root. This file doesn’t contain rules; it simply acts as a coordinator using Project References.

{
  "files": [],
  "references": [
    { "path": "./tsconfig/tsconfig.server.json" },
    { "path": "./tsconfig/tsconfig.client.json" }
  ]
}

Fixing the ESLint “Parsing Error”

Even with the TSConfigs fixed, ESLint was still complaining:

“Parsing error: ESLint was configured to run on … however, that TSConfig does not include this file.”

This happens because ESLint usually looks at one project config. When we split them, ESLint doesn’t know which file belongs to which config.

The fix is to use the TypeScript ESLint Project Service. Instead of telling ESLint “use this one file,” we tell it “ask TypeScript which config governs this file.”

// eslint.config.js
export default [
  {
    languageOptions: {
      parserOptions: {
        project: false, // Disable the old 'project' setting
        projectService: true, // Let TS decide the config per file
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
];

Implementing the Cache API (The Right Way)

With the configuration out of the way, using the Cloudflare Cache API became seamless.

When I first encountered the caches error, it was tempting to simply use (caches as any).default to silence the compiler. However, using any in a project meant to be type-safe is a slippery slope.

Instead, I relied on the worker-configuration.d.ts file. It’s worth emphasizing that We do not edit this file manually. It is generated automatically whenever you run the wrangler types command.

The CacheStorage type was already generated inside it. By splitting our configurations and excluding DOM typings from the server files, TypeScript was finally able to prioritize the Cloudflare types from worker-configuration.d.ts instead of defaulting to the conflicting browser definitions found in lib.dom.d.ts.

With this setup, I got full autocomplete and type safety without a single type cast. Now, in auth.server.ts, the code is clean and robust:

export async function authenticate(cookie: string) {
  const cache = caches.default; // Fully typed!
  const cacheKey = new Request(`https://lafyeri.internal/auth/${hash(cookie)}`);

  const cachedResponse = await cache.match(cacheKey);
  if (cachedResponse) return await cachedResponse.json();

  // ... fetch from API and cache the result
}

For Libraries like shadcn/ui

If you have components.json like this one:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "radix-mira",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "src/styles.css",
    "baseColor": "stone",
    "cssVariables": true,
    "prefix": ""
  },
  "iconLibrary": "lucide",
  "rtl": false,
  "aliases": {
    "components": "#/components",
    "utils": "#/lib/utils",
    "ui": "#/components/ui",
    "lib": "#/lib",
    "hooks": "#/hooks"
  },
  "menuColor": "default",
  "menuAccent": "subtle",
  "registries": {}
}

then you will need paths field in your tsconfig.json file. Otherwise your newly installed components will not be placed under src/components folder.

{
  "compilerOptions": {
    "paths": {
      "#/*": ["./src/*"],
      "@/*": ["./src/*"]
    }
  },
  "files": [],
  "references": [
    { "path": "./tsconfig/tsconfig.server.json" },
    { "path": "./tsconfig/tsconfig.client.json" }
  ]
}

The Runtime vs. Build-time Divide

One final nuance when dealing with a TanStack Start project on Cloudflare is managing your environment variables. You are generally dealing with two entirely different environment systems:

  1. The Runtime Env: These are the secrets, D1 databases, and KV namespaces accessed via import { env } from 'cloudflare:workers'. These only exist when the code is actually running on Cloudflare, and they are typed automatically via the generated worker-configuration.d.ts.
  2. The Build-time Env: These are variables prefixed with VITE_ that Vite embeds into the JavaScript bundle during the build process, accessed via import.meta.env.

I haven’t tested how VITE_ prefixed variables behave with this new layered TSConfig setup on the client side just yet. Because Vite handles these replacements dynamically at build time, it’s highly possible that they will work perfectly with the current state of the project without any additional typing on our part. I’m just not completely sure yet. I will be putting this to the test in the future and will follow up if any extra steps are needed.


Conclusion: Lessons Learned

Moving to a split configuration might seem like overkill at first, but it provides three critical benefits:

  1. Runtime Safety: You can no longer accidentally use window or document in a server function.
  2. IDE Performance: VS Code doesn’t have to load the entire project’s type graph for every file; it only loads the relevant config.
  3. Architectural Clarity: The /tsconfig folder separates the “how it’s built” (config) from the “what it does” (src).

When your project grows beyond a simple SPA, don’t be afraid to split your configs. A little bit of setup time saves hours of debugging “Cannot find module” or “Type mismatch” errors down the road.