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 typesgql.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
-
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 -
Use fragments for reusability: Define common field selections
-
Implement error boundaries: Wrap Apollo Provider with error handling
-
Cache management: Configure Apollo cache policies for your collections
-
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
- Schema introspection fails: Ensure your Directus instance allows introspection or use a static token for development
- Types not updating: Clear the generated files and re-run codegen
- Relationship types missing: Check that relationships are properly configured in Directus
- 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.