Sanity TypeGen: Optimal Project Structure and Production Workflow (2025 Update)

Scale TypeGen from basic setup to production-ready system with optimal project structure, advanced query organization, and bulletproof development workflow

·Matija Žiberna·
Sanity TypeGen: Optimal Project Structure and Production Workflow (2025 Update)

Sanity TypeGen: Optimal Project Structure and Production Workflow (2025 Update)

When I first wrote about generating TypeScript types for Sanity V3 schemas, I covered the basics of getting TypeGen up and running. That guide focused on the fundamental setup - extracting schemas, generating types, and using them in components. It was a solid foundation, but as my Sanity projects grew more complex, I discovered that the basic setup wasn't enough.

I recently rebuilt a blog platform where I had dozens of GROQ queries, complex sorting requirements, and a team of developers who needed consistent type safety. The basic TypeGen approach from my previous article worked, but I kept running into organizational challenges: queries scattered across files breaking TypeGen's analysis, dynamic query builders that couldn't be typed, import path inconsistencies causing build failures, and a general lack of structure that made the codebase hard to maintain.

This guide is the evolution of that original article - taking TypeGen from a basic setup to a production-ready system with optimal project structure, advanced query organization, and a bulletproof development workflow. If you followed my previous guide, this will show you how to scale it up. If you're starting fresh, this gives you the complete, battle-tested approach from day one.

Understanding TypeGen at Scale

Sanity TypeGen works by scanning your codebase for GROQ queries and generating TypeScript types based on your project's schema. While this sounds straightforward, the reality is that your project structure and query organization directly impact TypeGen's effectiveness. Poor organization leads to incomplete type generation, build failures, and a frustrating development experience.

The key insight I've learned is that TypeGen isn't just a tool you run - it's a system that needs to be architected properly. Your file structure, query patterns, and development workflow all need to work together to get the full benefits of automatic type generation.

Optimal Project Structure

After testing various approaches across multiple projects, this structure provides the best developer experience and maintainability:

src/lib/sanity/
├── client.ts              # Sanity client configuration
├── env.ts                 # Environment variables
├── image.ts               # Image URL utilities
├── queries/
│   └── queries.ts         # All GROQ queries centralized
├── schemaTypes/           # Schema definitions
│   ├── index.ts
│   ├── postType.ts
│   ├── categoryType.ts
│   └── authorType.ts
├── types/
│   └── index.ts           # Clean type exports and aliases
└── structure.ts           # Studio structure configuration

This organization solves several critical issues. First, centralizing all queries in a single file makes them easy for TypeGen to discover and analyze. Second, keeping all Sanity-related code under src/lib/sanity/ creates clear boundaries and consistent import paths. Third, the dedicated types folder provides a clean interface for consuming generated types throughout your application.

The structure also scales well - as you add more queries, they go into the central queries file. As you add more schema types, they get organized in the schemaTypes folder. The clear separation of concerns makes the codebase maintainable even as it grows.

Essential Configuration Files

Your sanity.cli.ts configuration at the project root needs to be complete for TypeGen to work properly:

// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'

const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET

export default defineCliConfig({ 
  api: { projectId, dataset } 
})

This configuration allows TypeGen to connect to your Sanity project and extract the schema information it needs. Without proper project connection, TypeGen can't understand your schema structure and will generate incomplete or incorrect types.

Your tsconfig.json must include the generated types file for TypeScript to recognize the generated types:

{
  "include": [
    "next-env.d.ts", 
    "**/*.ts", 
    "**/*.tsx", 
    ".next/types/**/*.ts",
    "sanity.types.ts"
  ]
}

The sanity.types.ts inclusion is crucial - without it, TypeScript won't recognize the generated types, and you'll lose all the autocomplete and type checking benefits that make TypeGen valuable.

Writing Production-Ready Queries

The most important requirement for reliable TypeGen is using the defineQuery function with proper variable naming. Here's the pattern that works consistently across all project sizes:

// src/lib/sanity/queries/queries.ts
import { defineQuery } from 'next-sanity'

export const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0] {
  _id,
  title,
  subtitle,
  metaDescription,
  slug,
  mainImage,
  body,
  markdownContent,
  publishedAt,
  dateModified,
  estimatedReadingTime,
  isHowTo,
  "author": author->{
    _id,
    name,
    slug,
    image,
    shortBio,
    bio,
    jobTitle,
    url,
    email
  },
  "categories": categories[]->{
    _id,
    title,
    slug,
    description,
    isHub
  }
}`)

export const POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)] | order(publishedAt desc) {
  _id,
  title,
  subtitle,
  metaDescription,
  slug,
  mainImage,
  publishedAt,
  dateModified,
  excerpt,
  isHowTo,
  estimatedReadingTime,
  keywords,
  claps,
  "author": author->{
    _id,
    name,
    slug,
    image,
    shortBio,
    bio,
    jobTitle,
    url,
    email
  },
  "categories": categories[]->title,
  "primaryCategory": categories[0]->{
    _id,
    title,
    slug,
    description,
    isHub,
    icon
  }
}`)

Each defineQuery call creates a query that TypeGen can analyze and generate types for. The variable names become the basis for the generated type names - POST_QUERY becomes POST_QUERYResult, POSTS_QUERY becomes POSTS_QUERYResult. This naming convention is predictable and makes it easy to know which types correspond to which queries.

Notice how the queries include comprehensive field selections. TypeGen generates types based on what your queries actually request, not what's available in your schema. If you don't select a field in your query, it won't appear in the generated type, regardless of whether it exists in your schema definition.

Solving Dynamic Query Problems

One critical issue that breaks TypeGen's static analysis is dynamic query generation. Functions that build queries at runtime can't be analyzed by TypeGen:

// This breaks TypeGen - avoid this pattern
export function buildPostsQuery(sortBy: string) {
  let orderClause = 'publishedAt desc'
  
  switch (sortBy) {
    case 'publishedAt_asc':
      orderClause = 'publishedAt asc'
      break
    case 'dateModified_desc':
      orderClause = 'dateModified desc'
      break
    case 'title_asc':
      orderClause = 'title asc'
      break
  }

  return `*[_type == "post"] | order(${orderClause}) { ... }`
}

TypeGen can't analyze this function because it doesn't know what the final query will look like at runtime. The solution is to create static queries for each variation you need:

// TypeGen can analyze these static queries
export const POSTS_BY_PUBLISHED_ASC_QUERY = defineQuery(`
  *[_type == "post" && defined(slug.current)
    && ($search == "" || title match $search + "*" || subtitle match $search + "*")
    && ($categoryFilter == "all" || references(*[_type == "category" && slug.current == $categoryFilter]._id))
    && ($authorFilter == "all" || author->slug.current == $authorFilter)
  ] | order(publishedAt asc)[$start...$end] {
    _id,
    title,
    subtitle,
    metaDescription,
    slug,
    mainImage,
    publishedAt,
    dateModified,
    excerpt,
    isHowTo,
    estimatedReadingTime,
    "author": author->{
      _id,
      name,
      slug,
      image,
      shortBio
    },
    "categories": categories[]->title,
    "primaryCategory": categories[0]->{
      _id,
      title,
      slug,
      description,
      isHub,
      icon
    }
  }
`)

export const POSTS_BY_DATE_MODIFIED_DESC_QUERY = defineQuery(`
  *[_type == "post" && defined(slug.current)
    && ($search == "" || title match $search + "*" || subtitle match $search + "*")
    && ($categoryFilter == "all" || references(*[_type == "category" && slug.current == $categoryFilter]._id))
    && ($authorFilter == "all" || author->slug.current == $authorFilter)
  ] | order(dateModified desc)[$start...$end] {
    _id,
    title,
    subtitle,
    metaDescription,
    slug,
    mainImage,
    publishedAt,
    dateModified,
    excerpt,
    isHowTo,
    estimatedReadingTime,
    "author": author->{
      _id,
      name,
      slug,
      image,
      shortBio
    },
    "categories": categories[]->title,
    "primaryCategory": categories[0]->{
      _id,
      title,
      slug,
      description,
      isHub,
      icon
    }
  }
`)

export const POSTS_BY_TITLE_ASC_QUERY = defineQuery(`
  *[_type == "post" && defined(slug.current)
    && ($search == "" || title match $search + "*" || subtitle match $search + "*")
    && ($categoryFilter == "all" || references(*[_type == "category" && slug.current == $categoryFilter]._id))
    && ($authorFilter == "all" || author->slug.current == $authorFilter)
  ] | order(title asc)[$start...$end] {
    _id,
    title,
    subtitle,
    metaDescription,
    slug,
    mainImage,
    publishedAt,
    dateModified,
    excerpt,
    isHowTo,
    estimatedReadingTime,
    "author": author->{
      _id,
      name,
      slug,
      image,
      shortBio
    },
    "categories": categories[]->title,
    "primaryCategory": categories[0]->{
      _id,
      title,
      slug,
      description,
      isHub,
      icon
    }
  }
`)

// Helper function for runtime query selection
export function getPostsQuery(sortBy: string) {
  switch (sortBy) {
    case 'publishedAt_asc':
      return POSTS_BY_PUBLISHED_ASC_QUERY
    case 'dateModified_desc':
      return POSTS_BY_DATE_MODIFIED_DESC_QUERY
    case 'title_asc':
      return POSTS_BY_TITLE_ASC_QUERY
    default:
      return POSTS_BY_PUBLISHED_ASC_QUERY
  }
}

This approach gives you the best of both worlds. Each static query gets proper type generation from TypeGen, while the helper function provides the runtime flexibility your application needs. Your components can call getPostsQuery(sortBy) and get back a properly typed query, while TypeGen can analyze each individual query variant.

The key insight is separating the concerns: static queries for TypeGen analysis, and helper functions for runtime selection. This pattern scales well - you can add new query variations by creating new static queries and updating the helper function.

Production Type Generation Workflow

Add these scripts to your package.json for a reliable development and deployment workflow:

{
  "scripts": {
    "generate:types": "sanity schema extract && sanity typegen generate",
    "dev": "pnpm generate:types && next dev --turbopack",
    "build": "pnpm generate:types && next build",
    "types:watch": "sanity typegen generate --watch",
    "types:check": "tsc --noEmit"
  }
}

The type generation process involves two steps that must run in sequence. First, sanity schema extract reads your schema definitions and creates a schema.json file. Then, sanity typegen generate analyzes both your schema and queries to generate the final TypeScript types.

Running generate:types before development and builds ensures your types are always current. This prevents the common problem of running with stale types that don't match your current queries or schema.

For active development, the types:watch script regenerates types automatically when you modify queries. This provides immediate feedback when you change query structures, helping you catch type errors as soon as they're introduced.

The types:check script validates that all your TypeScript code compiles correctly with the generated types. This is particularly useful in CI/CD pipelines to catch type errors before deployment.

Creating Clean Type Exports

Generated types can be verbose and difficult to import directly. Create a clean export system that makes the types more developer-friendly:

// src/lib/sanity/types/index.ts
import type {
  POST_QUERYResult,
  POSTS_QUERYResult,
  ENHANCED_POSTS_QUERYResult,
  ENHANCED_POSTS_FILTERED_QUERYResult,
  POSTS_BY_PUBLISHED_ASC_QUERYResult,
  POSTS_BY_DATE_MODIFIED_DESC_QUERYResult,
  POSTS_BY_TITLE_ASC_QUERYResult,
  CATEGORY_QUERYResult,
  ENHANCED_CATEGORY_QUERYResult,
  AUTHORS_QUERYResult,
  COMMAND_QUERYResult,
  COMMENTS_QUERYResult,
} from '../../../../sanity.types'

// Re-export all query result types
export type {
  POST_QUERYResult,
  POSTS_QUERYResult,
  ENHANCED_POSTS_QUERYResult,
  ENHANCED_POSTS_FILTERED_QUERYResult,
  POSTS_BY_PUBLISHED_ASC_QUERYResult,
  POSTS_BY_DATE_MODIFIED_DESC_QUERYResult,
  POSTS_BY_TITLE_ASC_QUERYResult,
  CATEGORY_QUERYResult,
  ENHANCED_CATEGORY_QUERYResult,
  AUTHORS_QUERYResult,
  COMMAND_QUERYResult,
  COMMENTS_QUERYResult,
  // Add new query result types here as needed
} from '../../../../sanity.types'

// Re-export schema types with cleaner names
export type {
  Post as SanityPost,
  Category as SanityCategory,
  Author as SanityAuthor,
  Comment as SanityComment,
  UsefulCommand as SanityCommand,
  BlockContent as SanityBlockContent,
  Slug as SanitySlug,
  SanityImageAsset,
  SanityImageHotspot,
  SanityImageCrop,
} from '../../../../sanity.types'

// Convenient type aliases for common usage patterns
export type BlogPost = NonNullable<POST_QUERYResult>
export type BlogPostPreview = POSTS_QUERYResult[0]
export type CategoryDetails = NonNullable<CATEGORY_QUERYResult>
export type CategoryWithPosts = NonNullable<ENHANCED_CATEGORY_QUERYResult>
export type AuthorProfile = AUTHORS_QUERYResult[0]
export type CommandDetails = NonNullable<COMMAND_QUERYResult>

// Utility types for common patterns
export type WithRequiredSlug<T> = T & {
  slug: {
    current: string
  }
}

export type PostWithRequiredFields = WithRequiredSlug<BlogPost> & {
  title: string
  publishedAt: string
}

This export system provides multiple layers of convenience. The direct re-exports give you access to the exact generated types when you need them. The renamed schema types provide cleaner names for general use. The type aliases handle common patterns like null-checking and array element access.

The utility types at the bottom provide reusable patterns for common requirements, like ensuring required fields exist or adding constraints to generated types. This approach scales well as your application grows and you discover more common type patterns.

Using Generated Types in Components

With proper setup, you can now use fully typed Sanity data throughout your application:

// src/app/(non-intl)/blog/page.tsx
import { sanityFetch } from '@/lib/sanity/client'
import { getPostsQuery, ENHANCED_POSTS_COUNT_QUERY, HUB_CATEGORIES_QUERY, AUTHORS_QUERY } from '@/lib/sanity/queries/queries'
import type { 
  ENHANCED_POSTS_FILTERED_QUERYResult,
  ENHANCED_POSTS_COUNT_QUERYResult,
  HUB_CATEGORIES_QUERYResult,
  AUTHORS_QUERYResult
} from '@/lib/sanity/types'

export default async function BlogPage({ searchParams }: BlogPageProps) {
  const filters = parseSearchParams(await searchParams)
  const currentPage = parseInt(filters.page, 10)
  const start = (currentPage - 1) * DEFAULT_ITEMS_PER_PAGE
  const end = start + DEFAULT_ITEMS_PER_PAGE - 1

  // Get the right static query based on sorting
  const postsQuery = getPostsQuery(filters.sortBy)

  // Fetch data with filters - all fully typed
  const [allPosts, totalCount, categories, authors] = await Promise.all([
    sanityFetch<ENHANCED_POSTS_FILTERED_QUERYResult>({
      query: postsQuery,
      params: {
        search: filters.search,
        typeFilter: filters.typeFilter,
        authorFilter: filters.authorFilter,
        categoryFilter: filters.categoryFilter,
        start,
        end
      },
      tags: ['post'],
    }),
    sanityFetch<ENHANCED_POSTS_COUNT_QUERYResult>({
      query: ENHANCED_POSTS_COUNT_QUERY,
      params: {
        search: filters.search,
        typeFilter: filters.typeFilter,
        authorFilter: filters.authorFilter,
        categoryFilter: filters.categoryFilter,
      },
      tags: ['post'],
    }),
    sanityFetch<HUB_CATEGORIES_QUERYResult>({
      query: HUB_CATEGORIES_QUERY,
      tags: ['category'],
    }),
    sanityFetch<AUTHORS_QUERYResult>({
      query: AUTHORS_QUERY,
      tags: ['author'],
    })
  ])

  // TypeScript knows all available fields
  return (
    <BlogContent 
      posts={allPosts}
      categories={categories}
      authors={authors}
      totalCount={totalCount}
      currentPage={currentPage}
      itemsPerPage={DEFAULT_ITEMS_PER_PAGE}
      filters={filters}
    />
  )
}

The generated types provide complete autocomplete support and catch type errors at compile time. When you modify queries, TypeScript immediately highlights any components that need updates, making refactoring safe and efficient.

Notice how the component uses the helper function getPostsQuery(filters.sortBy) to get the appropriate static query, then types the result with the specific query result type. This pattern gives you both runtime flexibility and compile-time type safety.

Avoiding Common Import Path Issues

During setup and scaling, you'll likely encounter module resolution errors. The key is establishing consistent import paths from the beginning and applying them systematically:

// Correct - consistent path structure throughout codebase
import { sanityFetch } from '@/lib/sanity/client'
import { POST_QUERY } from '@/lib/sanity/queries/queries'
import { urlForImage } from '@/lib/sanity/image'
import type { BlogPost } from '@/lib/sanity/types'

// Incorrect - mixed path structures cause build failures
import { sanityFetch } from '@/sanity/lib/client'
import { POST_QUERY } from '@/lib/sanity/queries/queries'
import { urlForImage } from '@/lib/sanity/image'
import type { BlogPost } from '@/sanity/types'

Inconsistent import paths will cause build failures, especially in production where the build process is more strict about module resolution. The solution is to establish a clear pattern early - in this case, everything Sanity-related lives under @/lib/sanity/ - and stick to it consistently.

When you encounter import errors, resist the temptation to fix them one-off. Instead, establish the correct pattern and fix all instances at once. This prevents the same issue from recurring and keeps your codebase consistent.

Production Development Workflow

The complete workflow I use for developing with TypeGen at scale:

  1. Start development: Run pnpm dev (automatically generates types first)
  2. Modify queries: Edit GROQ queries in your centralized queries file
  3. Regenerate types: Run pnpm generate:types or use watch mode during active development
  4. Update components: TypeScript will highlight any components that need changes due to type modifications
  5. Validate types: Run pnpm types:check to ensure all TypeScript code compiles correctly
  6. Build: pnpm build includes type generation automatically for production

This workflow ensures types are always current and catches integration issues immediately. The automatic type generation removes the manual overhead of maintaining type definitions while providing better type safety than any manual approach could achieve.

For team development, consider running type generation in a pre-commit hook to ensure all team members are working with current types. This prevents the common issue of one developer's query changes breaking another developer's code due to stale types.

Advanced Configuration Options

For larger projects, you may want to customize TypeGen's behavior with a sanity-typegen.json configuration file:

{
  "path": "./src/**/*.{ts,tsx}",
  "schema": "schema.json",
  "generates": "./sanity.types.ts",
  "overloadClientMethods": true,
  "nonNullableQueryKeys": true
}

The path option controls where TypeGen looks for queries. If you organize queries differently than the default, update this to match your structure. The overloadClientMethods option enables automatic type inference when using client.fetch(), which can be helpful for simpler use cases but may conflict with explicit typing in larger applications.

Production Deployment Considerations

For production deployments, include type generation in your build process. Most platforms handle this automatically if you include it in your build script, but consider these optimization strategies:

Option 1: Generate types during build (slower builds, always current types)

{
  "scripts": {
    "build": "pnpm generate:types && pnpm next build"
  }
}

Option 2: Pre-generate and commit types (faster builds, risk of stale types)

{
  "scripts": {
    "build": "pnpm next build"
  }
}

For most applications, Option 1 is safer because it ensures types are always current. The build time increase is usually minimal compared to the risk of deploying with incorrect types.

Consider adding generated files like schema.json to .gitignore since they're regenerated on each build. However, you may want to commit sanity.types.ts to make it easier to debug type-related issues in production.

Conclusion

Implementing Sanity TypeGen with proper project structure and workflow eliminates the maintenance overhead of manual type definitions while providing superior type safety and developer experience. The key principles are organizing your queries for TypeGen's static analysis, avoiding dynamic query generation, establishing consistent import patterns, and creating a reliable development workflow.

This production-ready approach scales from small projects to large applications with dozens of queries and multiple developers. The structured organization makes the codebase maintainable, the comprehensive typing catches errors early, and the automated workflow removes manual type maintenance entirely.

The investment in proper setup pays dividends immediately through better autocomplete, safer refactoring, and fewer runtime type errors. As your Sanity implementation grows, this foundation will continue to provide value without requiring significant maintenance or rework.

Let me know in the comments if you have questions about scaling TypeGen to your specific use case, and subscribe for more practical development guides.

Thanks,
Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in