← All Blogs

Type-Safe Directus with GraphQL Codegen: The Complete Guide

Type-Safe Directus with GraphQL Codegen

by Abdelkader Settah

August 30, 2025

Directus provides a powerful GraphQL API out of the box, but maintaining type safety between your CMS and frontend can be challenging. GraphQL Code Generator changes this by automatically generating TypeScript types, React hooks, and typed queries directly from your Directus GraphQL schema. This guide shows you how to set up a bulletproof type-safe integration that catches errors at compile time.

Why GraphQL Codegen with Directus?

While the REST API approach works, GraphQL with codegen offers superior benefits:

  • Automatic type generation from your live Directus schema
  • Precise field selection reducing over-fetching
  • Generated React hooks with full TypeScript support
  • Compile-time validation of queries against your schema
  • Auto-completion for all fields and relationships

Setting Up the Foundation

First, install the necessary dependencies:

npm install graphql graphql-request
npm install -D @graphql-codegen/cli @graphql-codegen/client-preset dotenv

Step 1: Configure GraphQL Codegen

Create a codegen.ts file in your project root:

// codegen.ts
import "dotenv/config";
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: process.env.NEXT_PUBLIC_DIRECTUS_URL + "/graphql",
  documents: "src/graphql/directus/**/*.graphql",
  generates: {
    "src/generated/directus/": {
      preset: "client",
      plugins: [],
      config: {
        scalars: {
          Date: "string",
          JSON: "Record<string, any>",
        },
      },
    },
  },
  ignoreNoDocuments: true,
};

export default config;

Add the codegen script to your package.json:

{
  "scripts": {
    "codegen": "graphql-codegen --config codegen.ts",
    "codegen:watch": "graphql-codegen --config codegen.ts --watch"
  }
}

Step 2: Set Up GraphQL Client with Directus

Create a GraphQL client using graphql-request for simplicity (you can also use Apollo Client or urql):

// src/lib/directus-client.ts
import { GraphQLClient } from "graphql-request";

export const directusClient = new GraphQLClient(
  `${process.env.NEXT_PUBLIC_DIRECTUS_URL}/graphql`,
  {
    headers: () => {
      const token = localStorage.getItem("directus_token");
      return token ? { authorization: `Bearer ${token}` } : {};
    },
  }
);

// Alternative: Create a hook for authenticated requests
export function useDirectusClient() {
  const token =
    typeof window !== "undefined"
      ? localStorage.getItem("directus_token")
      : null;

  return new GraphQLClient(`${process.env.NEXT_PUBLIC_DIRECTUS_URL}/graphql`, {
    headers: token ? { authorization: `Bearer ${token}` } : {},
  });
}

Step 3: Write Type-Safe GraphQL Queries

Create GraphQL queries in the designated folder:

# src/graphql/directus/articles.graphql

query GetArticles(
  $filter: articles_filter
  $limit: Int
  $offset: Int
  $sort: [String]
) {
  articles(filter: $filter, limit: $limit, offset: $offset, sort: $sort) {
    id
    title
    slug
    excerpt
    content
    status
    date_created
    date_updated
    author {
      id
      first_name
      last_name
      avatar {
        id
        filename_disk
      }
    }
    categories {
      categories_id {
        id
        name
        slug
      }
    }
    featured_image {
      id
      filename_disk
      title
      width
      height
    }
  }
}

query GetArticleBySlug($slug: String!) {
  articles(filter: { slug: { _eq: $slug } }, limit: 1) {
    id
    title
    slug
    content
    meta_description
    date_created
    author {
      id
      first_name
      last_name
      bio
    }
    related_articles {
      related_articles_id {
        id
        title
        slug
        excerpt
      }
    }
  }
}

mutation CreateArticle($data: create_articles_input!) {
  create_articles_item(data: $data) {
    id
    title
    slug
  }
}

Step 4: Generate Types and SDK

Run the codegen:

npm run codegen

This generates files in src/generated/directus/ including:

  • graphql.ts - All TypeScript types
  • gql.ts - The gql tag function
  • Individual operation files with typed document nodes

Step 5: Use Generated Code in Components

The client preset generates typed document nodes that you can use with any GraphQL client:

// src/components/ArticleList.tsx
import { graphql } from "@/generated/directus";
import { directusClient } from "@/lib/directus-client";
import { useQuery } from "@tanstack/react-query";

// The graphql function provides full type safety
const GetArticlesDocument = graphql(`
  query GetArticles($filter: articles_filter, $limit: Int) {
    articles(filter: $filter, limit: $limit) {
      id
      title
      slug
      excerpt
      author {
        first_name
        last_name
      }
    }
  }
`);

export function ArticleList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ["articles", { status: "published" }],
    queryFn: async () => {
      return directusClient.request(GetArticlesDocument, {
        filter: {
          status: { _eq: "published" },
        },
        limit: 10,
      });
    },
  });

  if (isLoading) return <div>Loading articles...</div>;
  if (error) return <div>Error loading articles</div>;

  return (
    <div className="grid gap-6">
      {data?.articles?.map((article) => (
        <article key={article.id} className="rounded border p-6">
          <h2 className="text-2xl font-bold">{article.title}</h2>
          <p className="text-gray-600">{article.excerpt}</p>
          {/* TypeScript knows all the fields! */}
          <span className="text-sm">
            By {article.author?.first_name} {article.author?.last_name}
          </span>
        </article>
      ))}
    </div>
  );
}

Alternative: Using with SWR

// src/hooks/useArticles.ts
import useSWR from "swr";
import { graphql } from "@/generated/directus";
import { directusClient } from "@/lib/directus-client";

const GetArticlesDocument = graphql(`
  query GetArticles($filter: articles_filter) {
    articles(filter: $filter) {
      id
      title
      slug
    }
  }
`);

export function useArticles(filter?: any) {
  return useSWR(
    ["articles", filter],
    () => directusClient.request(GetArticlesDocument, { filter }),
    {
      revalidateOnFocus: false,
    }
  );
}

Step 6: Creating Custom Hooks with Full Type Safety

Build reusable hooks that leverage the generated types:

// src/hooks/useDirectusQuery.ts
import { TypedDocumentNode } from "@graphql-typed-document-node/core";
import { useQuery, useMutation } from "@tanstack/react-query";
import { directusClient } from "@/lib/directus-client";

export function useDirectusQuery<TResult, TVariables>(
  document: TypedDocumentNode<TResult, TVariables>,
  variables?: TVariables,
  options?: any
) {
  return useQuery({
    queryKey: [document, variables],
    queryFn: async () => directusClient.request(document, variables),
    ...options,
  });
}

export function useDirectusMutation<TResult, TVariables>(
  document: TypedDocumentNode<TResult, TVariables>
) {
  return useMutation({
    mutationFn: async (variables: TVariables) =>
      directusClient.request(document, variables),
  });
}

Usage example:

// src/components/ArticleForm.tsx
import { graphql } from '@/generated/directus';
import { useDirectusMutation } from '@/hooks/useDirectusQuery';

const CreateArticleMutation = graphql(`
  mutation CreateArticle($data: create_articles_input!) {
    create_articles_item(data: $data) {
      id
      title
      slug
      status
    }
  }
`);

export function ArticleForm() {
  const { mutate, isPending } = useDirectusMutation(CreateArticleMutation);

  const handleSubmit = (formData: any) => {
    mutate({
      data: {
        title: formData.title,
        content: formData.content,
        status: 'draft',
        // TypeScript knows exactly what fields are available!
      },
    });
  };

  return (
    // Form JSX
  );
}

Step 7: Advanced Patterns

Working with Fragments

The client preset supports GraphQL fragments for reusable field selections:

# src/graphql/directus/fragments.graphql

fragment ArticleBase on articles {
  id
  title
  slug
  excerpt
  status
  date_created
  date_updated
}

fragment ArticleWithAuthor on articles {
  ...ArticleBase
  author {
    id
    first_name
    last_name
    avatar {
      id
      filename_disk
    }
  }
}

# Use in queries
query GetArticlesWithAuthor($limit: Int) {
  articles(limit: $limit) {
    ...ArticleWithAuthor
    categories {
      categories_id {
        name
      }
    }
  }
}

Dynamic Filters with Type Safety

// src/hooks/useArticleFilters.ts
import type { Articles_Filter } from "@/generated/directus/graphql";
import { useState, useMemo } from "react";

export function useArticleFilters() {
  const [search, setSearch] = useState("");
  const [category, setCategory] = useState<string | null>(null);
  const [status, setStatus] = useState<"published" | "draft" | null>(
    "published"
  );

  const filter = useMemo<Articles_Filter>(() => {
    const conditions: any[] = [];

    if (status) {
      conditions.push({ status: { _eq: status } });
    }

    if (category) {
      conditions.push({
        categories: {
          categories_id: {
            id: { _eq: category },
          },
        },
      });
    }

    if (search) {
      conditions.push({
        _or: [
          { title: { _contains: search } },
          { content: { _contains: search } },
        ],
      });
    }

    return conditions.length > 0 ? { _and: conditions } : {};
  }, [search, category, status]);

  return {
    filter,
    search,
    setSearch,
    category,
    setCategory,
    status,
    setStatus,
  };
}

Optimistic Updates with React Query

// src/components/ArticleEditor.tsx
import { graphql } from '@/generated/directus';
import { useDirectusMutation } from '@/hooks/useDirectusQuery';
import { useQueryClient } from '@tanstack/react-query';

const UpdateArticleMutation = graphql(`
  mutation UpdateArticle($id: ID!, $data: update_articles_input!) {
    update_articles_item(id: $id, data: $data) {
      id
      title
      content
      status
    }
  }
`);

export function ArticleEditor({ articleId }: { articleId: string }) {
  const queryClient = useQueryClient();
  const { mutate } = useDirectusMutation(UpdateArticleMutation);

  const handleSave = (formData: any) => {
    mutate(
      {
        id: articleId,
        data: {
          title: formData.title,
          content: formData.content,
        },
      },
      {
        onMutate: async (variables) => {
          // Cancel outgoing refetches
          await queryClient.cancelQueries({ queryKey: ['articles'] });

          // Snapshot previous value
          const previousArticle = queryClient.getQueryData(['article', articleId]);

          // Optimistically update
          queryClient.setQueryData(['article', articleId], (old: any) => ({
            ...old,
            ...variables.data,
          }));

          return { previousArticle };
        },
        onError: (err, variables, context) => {
          // Rollback on error
          if (context?.previousArticle) {
            queryClient.setQueryData(['article', articleId], context.previousArticle);
          }
        },
        onSettled: () => {
          // Always refetch after error or success
          queryClient.invalidateQueries({ queryKey: ['articles'] });
        },
      }
    );
  };

  return (
    // Editor JSX
  );
}

Step 8: Handling Directus Permissions and Auth

Create a context for managing authentication state:

// src/contexts/DirectusAuthContext.tsx
import { createContext, useContext, useState, useEffect } from "react";
import { directusClient } from "@/lib/directus-client";
import { graphql } from "@/generated/directus";

const LoginMutation = graphql(`
  mutation Login($email: String!, $password: String!) {
    auth_login(email: $email, password: $password) {
      access_token
      refresh_token
      expires
    }
  }
`);

const GetCurrentUser = graphql(`
  query GetCurrentUser {
    users_me {
      id
      first_name
      last_name
      email
      role {
        id
        name
      }
    }
  }
`);

interface AuthContextType {
  user: any | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
}

const AuthContext = createContext<AuthContextType | null>(null);

export function DirectusAuthProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  const login = async (email: string, password: string) => {
    const { auth_login } = await directusClient.request(LoginMutation, {
      email,
      password,
    });

    if (auth_login?.access_token) {
      localStorage.setItem("directus_token", auth_login.access_token);
      if (auth_login.refresh_token) {
        localStorage.setItem(
          "directus_refresh_token",
          auth_login.refresh_token
        );
      }

      // Fetch user data
      const { users_me } = await directusClient.request(GetCurrentUser);
      setUser(users_me);
    }
  };

  const logout = () => {
    localStorage.removeItem("directus_token");
    localStorage.removeItem("directus_refresh_token");
    setUser(null);
  };

  useEffect(() => {
    const checkAuth = async () => {
      const token = localStorage.getItem("directus_token");
      if (token) {
        try {
          const { users_me } = await directusClient.request(GetCurrentUser);
          setUser(users_me);
        } catch {
          logout();
        }
      }
      setIsLoading(false);
    };

    checkAuth();
  }, []);

  return (
    <AuthContext.Provider value={{ user, login, logout, isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context)
    throw new Error("useAuth must be used within DirectusAuthProvider");
  return context;
};

Project Structure

Here’s the recommended folder structure for your Directus + GraphQL Codegen setup:

src/
├── generated/
│   └── directus/
│       ├── gql.ts           # gql tag function
│       ├── graphql.ts       # All generated types
│       └── index.ts         # Exports
├── graphql/
│   └── directus/
│       ├── articles.graphql # Article queries/mutations
│       ├── auth.graphql     # Authentication operations
│       ├── users.graphql    # User queries
│       └── fragments.graphql # Reusable fragments
├── lib/
│   └── directus-client.ts   # GraphQL client setup
├── hooks/
│   ├── useDirectusQuery.ts  # Generic query hook
│   └── useArticles.ts       # Domain-specific hooks
└── contexts/
    └── DirectusAuthContext.tsx # Auth management

Best Practices

  1. Run codegen in CI/CD: Ensure types are always in sync

    # .github/workflows/codegen.yml
    - name: Generate GraphQL Types
      run: npm run codegen
    - name: Check for uncommitted changes
      run: git diff --exit-code
  2. Use fragments for reusability: Define common field selections

  3. Implement error boundaries: Wrap Apollo Provider with error handling

  4. Cache management: Configure Apollo cache policies for your collections

  5. Separate queries by domain: Organize queries in feature-based files

Performance Optimization

Configure persisted queries for production:

// src/lib/apollo-client.ts
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";

const persistedQueriesLink = createPersistedQueryLink({
  sha256,
  useGETForHashedQueries: true,
});

const link = ApolloLink.from([authLink, persistedQueriesLink, httpLink]);

Troubleshooting Common Issues

  1. Schema introspection fails: Ensure your Directus instance allows introspection or use a static token for development
  2. Types not updating: Clear the generated files and re-run codegen
  3. Relationship types missing: Check that relationships are properly configured in Directus
  4. Scalar type errors: Add custom scalar mappings in codegen.yml

Conclusion

GraphQL Code Generator with Directus provides an unbeatable developer experience. You get complete type safety from your CMS to your frontend components, with auto-generated hooks that make data fetching trivial. This approach scales from simple blogs to complex enterprise applications, ensuring your code remains maintainable as your content model evolves.

The combination of Directus’s flexible CMS, GraphQL’s precise data fetching, and TypeScript’s type safety creates a robust foundation that catches errors early and speeds up development through excellent IDE support.

Further Reading