Practical Schema-Driven Development: Efficient API Design with React × Express × GraphQL

  • graphql
    graphql
  • typescript
    typescript
  • expressjs
    expressjs
  • react
    react
  • vite
    vite
  • apollo
    apollo
Published on 2024/10/12

Introduction

In recent years, Schema-Driven Development (SDD) has been attracting attention as an approach to streamline frontend and backend development.
In SDD using GraphQL, you first define the GraphQL schema and then implement the backend and frontend based on that schema.
This clarifies the API specifications and makes it possible to develop the frontend and backend in parallel.

In this article, we will explain how to practice schema-driven development using React (Vite) + Express (Apollo Server).
We will also achieve type-safe development by automatically generating TypeScript types from GraphQL schemas.

By reading this article, you will learn:

  • Benefits of schema-driven development
  • Building a GraphQL API using React (Vite) + Express (Apollo Server)
  • Automatically generating TypeScript types from GraphQL schemas
  • How to connect the frontend and backend

What is Schema-Driven Development?

Schema-Driven Development (SDD) is a development methodology in which API specifications are designed and implemented based on GraphQL schema definitions (SDL: Schema Definition Language).

With traditional REST APIs, you needed to create a separate API specification document and share it between the frontend and backend. However, in GraphQL, the schema itself becomes the API specification, which makes the development process smoother.

Benefits

  • Clear specifications
    Since the schema itself is the API specification, it reduces the likelihood of misunderstandings about the specs between the frontend and backend.

  • Automatic documentation
    Using tools like GraphQL Playground or Apollo Studio, you can automatically generate documentation from schema definitions, making it easier for API consumers to understand and use the API.

  • Easy detection of changes
    When there are changes to the schema, consistency checks with the implementation are more likely to occur automatically, making the impact of changes clearer.

By following this process, you can improve the overall efficiency of the team and achieve smooth communication and project management.

Goal of This Tutorial

We will proceed with the implementation in the following steps:

  1. Create a GraphQL schema definition
  2. Load the schema definition on the server and start a GraphQL server
  3. Generate TypeScript type definitions from the GraphQL schema and GraphQL queries
  4. Fetch data from the server via GraphQL from the frontend

In this tutorial, we will use post data as a sample,
and in the end we will display the post titles and author information in the browser.

Image from Gyazo

The code implemented in this tutorial is stored in the following repository, so please refer to it as well.

https://github.com/shinagawa-web/schema-driven-development-react-express-graphql

Setup

First, we start with the overall project setup.

mkdir Documents/workspace/schema-driven-development-react-express-graphql
cd Documents/workspace/schema-driven-development-react-express-graphql
npm init -y

Install Turborepo so we can use it.

npm install turbo -D

Turborepo is a tool for managing monorepo structures, mainly used in JavaScript and TypeScript projects. A monorepo is an approach where multiple projects or packages are managed within a single repository. Turborepo is a tool designed to efficiently handle such monorepos.

Next, configure Turborepo.
You can define tasks for things like build and starting in development mode.

turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Next, configure the root-level package.json.

package.json
{
  "name": "react-express-graphql-turborepo",
+ "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "version": "1.0.0",
  "description": "",
+ "packageManager": "npm@10.9.2",
- "main": "index.js",
  "scripts": {
+   "dev": "turbo dev",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "turbo": "^2.4.0"
  }
}
  • workspaces: A setting for building a monorepo. A monorepo is an approach where multiple packages or applications are managed in a single repository.
    By using workspaces, you can efficiently manage different packages (applications and libraries) within the same repository, share dependencies, speed up installation, and improve development efficiency.

Creating the Frontend (React + Vite)

We will start with the frontend configuration.

mkdir apps
cd apps
npm create vite@latest frontend

When building the frontend with Vite, you will be asked a few questions; configure them as follows:

Select a framework: React
Select a variant: TypeScript + SWC

Next, configure the scoped package.

apps/package.json
{
-  "name": "frontend",
+  "name": "@react-express-graphql-turborepo/frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.21.0",
    "@types/react": "^19.0.10",
    "@types/react-dom": "^19.0.4",
    "@vitejs/plugin-react-swc": "^3.8.0",
    "eslint": "^9.21.0",
    "eslint-plugin-react-hooks": "^5.1.0",
    "eslint-plugin-react-refresh": "^0.4.19",
    "globals": "^15.15.0",
    "typescript": "~5.7.2",
    "typescript-eslint": "^8.24.1",
    "vite": "^6.2.0"
  }
}

Now that we have downloaded the necessary files for Vite and React, install the packages.

cd apps/frontend
npm install

After installation, start the app to make sure it works.

npm run dev

If you can access http://localhost:5173/, the frontend setup is complete.

Creating the Backend (Express.js)

Next, configure Express as the backend.

First, install the packages.

mkdir apps/backend && cd apps/backend
npm init -y
npm i express cors dotenv
npm i -D  typescript ts-node @types/node @types/express @types/cors

Then configure the scoped package and tsconfig.json.

apps/backend/package.json
{
- "name": "backend",
+ "name": "@react-express-graphql-turborepo/backend",
  "version": "1.0.0",
tsconfig.json
{
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "module": "CommonJS",
    "target": "ES6",
    "esModuleInterop": true
  }
}

Now that we have an environment where Express can run, create the startup file.

apps/backend/src/index.ts
import express from "express";
import cors from "cors";

const app = express();
const PORT = process.env.PORT || 4000;

app.use(cors());
app.use(express.json());

app.get("/", (req, res) => {
  res.send("Hello from Express!");
});

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});
  • When you send a GET request to /, the string Hello from Express! is returned.

After creating the file, define the startup script in package.json.

package.json
- "main": "index.js",
+ "main": "index.ts",
  "scripts": {
+   "dev": "ts-node src/index.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

Check that Express can start.

npm run dev
Server is running on http://localhost:4000

Once it is running, check the response with curl.

curl http://localhost:4000
Hello from Express!

This completes the Express setup.

Connecting the Frontend and Backend

We will use the fetch API to access Express and display the GET response Hello from Express! in the browser.

apps/frontend/src/App.tsx
import { useEffect, useState } from "react";

function App() {
  const [message, setMessage] = useState("");

  useEffect(() => {
    fetch("http://localhost:4000")
      .then((res) => res.text())
      .then((data) => setMessage(data));
  }, []);

  return (
    <div>
      <h1>React + Express</h1>
      <p>API Response: {message}</p>
    </div>
  );
}

export default App;

The default CSS is still there and affects the layout, so remove it for now.

rm apps/frontend/src/index.css
rm apps/frontend/src/App.css

Also update the files that import the CSS.

apps/frontend/src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
- import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

Now that everything is configured, start React and Express via Turborepo.
Run the following command at the project root.

npm run dev

Image from Gyazo

We have managed the frontend and backend repositories with Turborepo and connected both sides.

From here, we will move on to the main topic of this article: “Schema-Driven Development.”

Creating the Schema Definition

To make the GraphQL definitions accessible from both the frontend and backend, we will place them in a shared directory in the monorepo.

mkdir apps/packages
mkdir apps/packages/graphql
mkdir apps/packages/graphql/schema

This GraphQL schema defines an API that handles blog posts (Post) and their authors (Author).

apps/packages/graphql/schema/post.graphql
type Author {
  id: Int!
  firstName: String!
  lastName: String!
}
 
type Post {
  id: Int!
  title: String!
  author: Author
}
 
type Query {
  posts: [Post] # Retrieve all posts
  post(id: ID!): Post # Retrieve a single post
}
  • Author is the author who wrote the post (Post)

    • id: Int! → Unique ID of the author (! means the field is required)
    • firstName: String! → Author’s first name (required)
    • lastName: String! → Author’s last name (required)
  • Post is the blog post data

    • id: Int! → Unique ID of the post (required)
    • title: String! → Title of the post (required)
    • author: Author → Author of the post (Author type)
  • By defining author: Author, you can retrieve the author (Author) from a post (Post).

  • Endpoints that the client can call

    • posts: [Post] → Retrieve all posts
    • post(id: ID!): Post → Retrieve a specific post by ID
      • id: ID! is GraphQL’s ID type (usually stores String or Int)

Backend Implementation

To start a GraphQL server with Express, install a few libraries.

npm i @apollo/server graphql graphql-tag graphql-tools/load-files
npm i -D @types/express-serve-static-core
Package name Description
@apollo/server Apollo Server (a server library that provides a GraphQL API)
graphql Core GraphQL library (parsing schemas, validation, etc.)
graphql-tag Utility for parsing GraphQL queries (gql templates)
graphql-tools/load-files Library for loading GraphQL schemas and resolvers from files
@types/express-serve-static-core Type definitions for express (including internal features like serve-static)

Next, define the resolvers.

// Dummy data
const posts = [
  { id: 1, title: "GraphQL Introduction", authorId: 1 },
  { id: 2, title: "Advanced GraphQL", authorId: 1 },
  { id: 3, title: "GraphQL with Apollo", authorId: 2 },
];

export const resolvers = {
  Query: {
    // Retrieve all posts
    posts: () => posts,

    // Retrieve a specific post by ID
    post: (_parent: any, args: { id: number }) =>
      posts.find((post) => post.id === args.id),
  },
};
  • Resolver corresponding to GraphQL Query.posts

    • Returns all posts (the posts array)
    • Works easily without a database
  • Resolver corresponding to GraphQL Query.post(id: ID!)

    • Searches for the post whose post.id matches args.id
    • Returns the matching post, or undefined if not found
    • If there is no data, it may become null, so the client side needs to handle that

Next, configure loading the GraphQL schema (.graphql file) and passing the schema definition to Apollo Server.

apps/backend/src/graphql/type.ts
import { readFileSync } from "fs";
import { gql } from "graphql-tag";
import path from "path";

const schemaPath = path.resolve(process.cwd(), "../packages/graphql/schema/post.graphql");

const typeDefs = gql(readFileSync(schemaPath, "utf-8"));

export { typeDefs };
  • Parses GraphQL queries and schemas as gql (GraphQL tag)
    • graphql-tag is a library that parses GraphQL schemas read as strings using the gql tag
    • By using gql, you can correctly pass the schema to Apollo Server
apps/backend/src/index.ts
import express from "express";
import cors from "cors";
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { resolvers } from './graphql/resolvers';
import { typeDefs } from "./graphql/type";

const server = new ApolloServer({ typeDefs, resolvers });

const app = express();
const PORT = process.env.PORT || 4000;

const startServer = async () => {
  await server.start();

  app.use(
    '/graphql',
    cors(),
    express.json(),
    expressMiddleware(server)
  );

  app.listen(PORT, () => {
    console.log('GraphQL server is running at http://localhost:4000/graphql');
  });
};

startServer();
  • Libraries

    • express → Framework for creating HTTP servers
    • cors → Middleware to allow CORS (cross-origin requests)
    • @apollo/server → Apollo Server (library that provides a GraphQL API)
    • expressMiddleware → Function to integrate Apollo Server as middleware in Express
    • resolvers → GraphQL resolvers (functions that handle queries)
    • typeDefs → GraphQL schema (defines the structure of the data)
  • Create the GraphQL server (Apollo Server)

    • Pass typeDefs (schema) and resolvers (data fetching logic) to Apollo Server
    • This server provides the GraphQL API endpoint
  • Integrate the GraphQL server (server) into Express and start it

    • server.start() → Start Apollo Server
    • app.use('/graphql', ...) → Create the /graphql endpoint
      • cors() → Allow CORS (do not restrict requests from the browser)
      • express.json() → Parse JSON requests
      • expressMiddleware(server) → Register Apollo Server as Express middleware
    • app.listen(PORT, () => { ... }) → Start the server on the specified port (4000)

After finishing the configuration, start the Express server and check if you get a response with curl.

curl -X POST http://localhost:4000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ posts { id title } }"}' | jq

If the dummy data defined in the resolvers is returned as a response, the server-side setup is complete.

{
  "data": {
    "posts": [
      {
        "id": 1,
        "title": "GraphQL Introduction"
      },
      {
        "id": 2,
        "title": "Advanced GraphQL"
      },
      {
        "id": 3,
        "title": "GraphQL with Apollo"
      }
    ]
  }
}

Bonus: Filtering by title

We will add a setting to filter by title when executing the query.

export const resolvers = {
  Query: {
-   // Retrieve all posts
+   // Retrieve all posts with optional title filtering
-   posts: () => posts,
+   posts: (_parent: any, args: { findTitle?: string }) => {
+     let filteredPosts = posts;

+     if (args.findTitle) {
+       filteredPosts = filteredPosts.filter((post) =>
+         post.title.toLowerCase().includes(args.findTitle!.toLowerCase())
+       );
+     }

      return filteredPosts;
    },

    // Retrieve a specific post by ID
    post: (_parent: any, args: { id: number }) =>
      posts.find((post) => post.id === args.id),
  },
};
  • If findTitle is provided, it returns posts whose titles partially match it.
  • If findTitle is not provided, it returns all posts.

Set findTitle as an argument in the query definition.

apps/packages/graphql/schema/post.graphql
type Query {
- posts: [Post]
+ posts(findTitle: String): [Post] # Allow filtering by title
  post(id: ID!): Post # Retrieve a single post
}

curl

$ curl -X POST http://localhost:4000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ posts(findTitle: \"Apollo\") { id title } }"}' | jq
{
  "data": {
    "posts": [
      {
        "id": 3,
        "title": "GraphQL with Apollo"
      }
    ]
  }
}

Only titles containing the word “Apollo” are included in the response.

Automatically Generating TypeScript Types from the Schema

Next, to use TypeScript types on the frontend, we will generate them from the GraphQL definitions.

Install GraphQL Code Generator:

npm i -D @graphql-codegen/cli

Create a configuration file for GraphQL Code Generator (@graphql-codegen/cli) and configure it to automatically generate typed TypeScript code from GraphQL schemas and queries.

apps/frontend/config/codegen.ts
import { CodegenConfig } from '@graphql-codegen/cli';
 
const config: CodegenConfig = {
  schema: '../packages/graphql/schema/**/*.graphql',
  generates: {
    "generated/graphql.ts": {
      plugins: [
        'typescript',
        'typescript-operations',
      ],
    },
  } 
};
export default config;
  • schema: Specifies the path to the GraphQL schema files

    • ../packages/graphql/schema/**/*.graphql
      • Targets all .graphql files under packages/graphql/schema/
      • **/*.graphql recursively finds all GraphQL schemas
    • The schema includes type definitions such as type Query, type Mutation, type Post, etc.
  • generates: Configures which file to generate types into

    • "generated/graphql.ts" → Generates TypeScript types into generated/graphql.ts
    • plugins → Specifies which plugins to use
  • plugins: Plugins that generate types from GraphQL schemas and queries

    • 'typescript' → Generates TypeScript types from GraphQL schemas (schema.graphql)
    • 'typescript-operations' → Generates TypeScript types from GraphQL queries (.graphql)
apps/frontend/package.json
{
  "name": "@react-express-graphql-turborepo/frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview",
+   "codegen": "graphql-codegen --config scripts/codegen.ts"
  },
  • Configure the command to run graphql-codegen.
npm run codegen
  • TypeScript types will be generated in generated/graphql.ts.
apps/frontend/generated/graphql.ts
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: { input: string; output: string; }
  String: { input: string; output: string; }
  Boolean: { input: boolean; output: boolean; }
  Int: { input: number; output: number; }
  Float: { input: number; output: number; }
};

export type Author = {
  __typename?: 'Author';
  firstName: Scalars['String']['output'];
  id: Scalars['Int']['output'];
  lastName: Scalars['String']['output'];
  posts?: Maybe<Array<Maybe<Post>>>;
};


export type AuthorPostsArgs = {
  findTitle?: InputMaybe<Scalars['String']['input']>;
};

export type Post = {
  __typename?: 'Post';
  author?: Maybe<Author>;
  id: Scalars['Int']['output'];
  title: Scalars['String']['output'];
};

export type Query = {
  __typename?: 'Query';
  post?: Maybe<Post>;
  posts?: Maybe<Array<Maybe<Post>>>;
};


export type QueryPostArgs = {
  id: Scalars['ID']['input'];
};

Now we can generate TypeScript type definitions from GraphQL schema definitions.

Even if you create multiple GraphQL schema definitions, you can generate TypeScript type definitions for all of them at once.

apps/packages/graphql/schema/recipe.graphql
type Recipe {
  id: Int!
  title: String!
  imageUrl: String
}

type Query {
  recipe(id: Int!): Recipe!
}

Run it again:

npm run codegen
apps/frontend/generated/graphql.ts
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: { input: string; output: string; }
  String: { input: string; output: string; }
  Boolean: { input: boolean; output: boolean; }
  Int: { input: number; output: number; }
  Float: { input: number; output: number; }
};

export type Author = {
  __typename?: 'Author';
  firstName: Scalars['String']['output'];
  id: Scalars['Int']['output'];
  lastName: Scalars['String']['output'];
  posts?: Maybe<Array<Maybe<Post>>>;
};


export type AuthorPostsArgs = {
  findTitle?: InputMaybe<Scalars['String']['input']>;
};

export type Post = {
  __typename?: 'Post';
  author?: Maybe<Author>;
  id: Scalars['Int']['output'];
  title: Scalars['String']['output'];
};

export type Query = {
  __typename?: 'Query';
  post?: Maybe<Post>;
  posts?: Maybe<Array<Maybe<Post>>>;
  recipe: Recipe;
};


export type QueryPostArgs = {
  id: Scalars['ID']['input'];
};


export type QueryRecipeArgs = {
  id: Scalars['Int']['input'];
};

export type Recipe = {
  __typename?: 'Recipe';
  id: Scalars['Int']['output'];
  imageUrl?: Maybe<Scalars['String']['output']>;
  title: Scalars['String']['output'];
};

You can see that Query now has both posts and recipe, and they are handled together.

Frontend Implementation

First, create the GraphQL query.

This GraphQL query GetPosts retrieves all posts (posts) and, for each post, fetches its id, title, and author information.

apps/packages/graphql/operations/getPost.graphql
query GetPosts {
  posts {
    id
    title
    author {
      firstName
      lastName
    }
  }
}

Add settings to config.ts so that GraphQL queries can also be used for type generation.

apps/frontend/config/config.ts
import { CodegenConfig } from '@graphql-codegen/cli';
 
const config: CodegenConfig = {
  schema: '../packages/graphql/schema/**/*.graphql',
+ documents: '../packages/graphql/operations/**/*.graphql',
  generates: {
    "generated/graphql.ts": {
      plugins: [
        'typescript',
        'typescript-operations',
      ],
    },
  } 
};
export default config;

Then the following GetPostsQuery will be generated.
This is the return type of getPosts.

export type GetPostsQuery = { 
  __typename?: 'Query', 
  posts?: Array<{
     __typename?: 'Post', 
     id: number, 
     title: string, 
     author?: { 
      __typename?: 'Author', 
      firstName: string, 
      lastName: string 
    } | null 
  } | null> | null 
};
npm i -D @graphql-codegen/typescript-graphql-request
npm i graphql-request

@graphql-codegen/typescript-graphql-request is one of the GraphQL Code Generator plugins. It generates TypeScript types and client code suitable for graphql-request from GraphQL schemas and queries.

apps/frontend/config/config.ts
import { CodegenConfig } from '@graphql-codegen/cli';
 
const config: CodegenConfig = {
  schema: '../packages/graphql/schema/**/*.graphql',
  documents: '../packages/graphql/operations/**/*.graphql',
  generates: {
    "generated/graphql.ts": {
      plugins: [
        'typescript',
        'typescript-operations',
+       'typescript-graphql-request',
      ],
    },
  } 
};
export default config;

After adding this to the Code Generator config and regenerating the types, the following will be added:

export const GetPostsDocument = gql`
    query GetPosts {
  posts {
    id
    title
    author {
      firstName
      lastName
    }
  }
}
    `;

export type SdkFunctionWrapper = <T>(action: (requestHeaders?:Record<string, string>) => Promise<T>, operationName: string, operationType?: string, variables?: any) => Promise<T>;


const defaultWrapper: SdkFunctionWrapper = (action, _operationName, _operationType, _variables) => action();

export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
  return {
    GetPosts(variables?: GetPostsQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise<GetPostsQuery> {
      return withWrapper((wrappedRequestHeaders) => client.request<GetPostsQuery>(GetPostsDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'GetPosts', 'query', variables);
    }
  };
}
export type Sdk = ReturnType<typeof getSdk>;
service.ts
import { GraphQLClient } from "graphql-request";
import { getSdk } from "../generated/graphql"; 

const client = new GraphQLClient("http://localhost:4000/graphql");
const sdk = getSdk(client);

export async function getPost() {
  const response = await sdk.GetPosts();
  return response.posts
}

Key points:

  • graphql-request is a simple GraphQL client library used to send HTTP requests to a GraphQL API.
  • getSdk is an API client automatically generated by GraphQL Code Generator.
    • Using graphql-code-generator, you can convert GraphQL queries into TypeScript-typed functions (getSdk).
    • By using getSdk, you can easily execute GraphQL queries and fetch data in a type-safe way.
import { useEffect, useState } from "react";
import { GetPostsQuery } from "../generated/graphql";
import { getPosts } from "./service";

function App() {
  const [posts, setPosts] = useState<GetPostsQuery["posts"] | null>(null);

  useEffect(() => {
    getPosts()
      .then((data) => {
        setPosts(data);
      })
      .catch((error) => {
        console.error(error);
      });
  }, []);

  return (
    <div>
      <h1>React + Express</h1>
      {posts === null || posts === undefined ? (
        <p>Loading...</p>
      ) : posts.length === 0 ? (
        <p>No posts found.</p>
      ) : (
        <ul>
          {posts.map((post) => (
            <li key={post?.id}>
              <h2>{post?.title}</h2>
              <p>Author: {post?.author?.firstName} {post?.author?.lastName}</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default App;
  • GetPostsQuery can be used as the return type of getPosts.

Image from Gyazo

Checking the result, the post titles are displayed, but the authors are not.

Let’s check the resolvers again.
It seems we forgot to set the author information when returning the list of posts.

export const resolvers = {
  Query: {
  // Retrieve all posts with optional title filtering
  posts: (_parent: any, args: { findTitle?: string }) => {
    let filteredPosts = posts;

    if (args.findTitle) {
      filteredPosts = filteredPosts.filter((post) =>
        post.title.toLowerCase().includes(args.findTitle!.toLowerCase())
      );
    }

      return filteredPosts;
    },

    // Retrieve a specific post by ID
    post: (_parent: any, args: { id: number }) =>
      posts.find((post) => post.id === args.id),
  },
};

We will fix it so that author information is set.
For now, we will also handle this with dummy data.

// Dummy data
+ const authors = [
+   { id: 1, firstName: "John", lastName: "Doe" },
+   { id: 2, firstName: "Jane", lastName: "Smith" },
+ ];

const posts = [
  { id: 1, title: "GraphQL Introduction", authorId: 1 },
  { id: 2, title: "Advanced GraphQL", authorId: 1 },
  { id: 3, title: "GraphQL with Apollo", authorId: 2 },
];

export const resolvers = {
  Query: {
    // Retrieve all posts with optional title filtering + add `author`
    posts: (_parent: any, args: { findTitle?: string }) => {
+    let filteredPosts = posts;

      if (args.findTitle) {
        filteredPosts = filteredPosts.filter((post) =>
          post.title.toLowerCase().includes(args.findTitle!.toLowerCase())
        );
      }

+     // Convert `authorId` to `author`
+     return filteredPosts.map((post) => ({
+       ...post,
+       author: authors.find((author) => author.id === post.authorId) || null,
+     }));
    },

    // Retrieve a specific post by ID + add `author`
    post: (_parent: any, args: { id: number }) => {
      const post = posts.find((post) => post.id === args.id);
      if (!post) return null;

      return {
        ...post,
        author: authors.find((author) => author.id === post.authorId) || null,
      };
    },
  },
};

Checking again in the browser, the author information is now correctly set.

Image from Gyazo

Directory Structure

Finally, let’s check the directory structure and review the overall picture.

tree -I node_modules -I .git -I .turbo --dirsfirst -a
.
├── apps
│   ├── backend
│   │   ├── dist
│   │   │   ├── graphql
│   │   │   │   ├── resolvers.js
│   │   │   │   └── type.js
│   │   │   └── index.js
│   │   ├── src
│   │   │   ├── graphql
│   │   │   │   ├── resolvers.ts
│   │   │   │   └── type.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── tsconfig.tsbuildinfo
│   ├── frontend
│   │   ├── config
│   │   │   └── codegen.ts
│   │   ├── generated
│   │   │   └── graphql.ts
│   │   ├── public
│   │   │   └── vite.svg
│   │   ├── src
│   │   │   ├── assets
│   │   │   │   └── react.svg
│   │   │   ├── App.tsx
│   │   │   ├── main.tsx
│   │   │   ├── service.ts
│   │   │   └── vite-env.d.ts
│   │   ├── .gitignore
│   │   ├── README.md
│   │   ├── eslint.config.js
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── tsconfig.app.json
│   │   ├── tsconfig.json
│   │   ├── tsconfig.node.json
│   │   └── vite.config.ts
│   └── packages
│       └── graphql
│           ├── operations
│           │   └── getPost.graphql
│           └── schema
│               ├── post.graphql
│               └── recipe.graphql
├── package-lock.json
├── package.json
└── turbo.json

Project Top Level

.
├── apps          # Manages each application (`backend`, `frontend`)
├── packages      # Shared packages (GraphQL schemas, queries, etc.)
├── package.json  # Project-wide dependencies
├── turbo.json    # `Turborepo` configuration file
  • Monorepo structure using Turborepo
  • apps/ → Separate code for backend and frontend
  • packages/ → Store shared GraphQL schemas (graphql/)
  • turbo.json → Turborepo configuration file

apps/backend/ (Backend GraphQL API)

├── backend
│   ├── dist       # Compiled JavaScript (`.js`) from TypeScript
│   ├── src        # Backend TypeScript (`.ts`) source code
│   ├── package.json  # Backend dependencies
│   ├── tsconfig.json  # TypeScript configuration
Folder / File Description
dist/ JavaScript files built from TypeScript
src/ Backend TypeScript source code
src/graphql/ GraphQL resolvers (resolvers.ts), schema loader (type.ts)
index.ts Main entry point (Express + Apollo Server)
package.json Backend dependencies
tsconfig.json TypeScript configuration

apps/frontend/ (Frontend React App)

├── frontend
│   ├── config        # Configuration files (`codegen.ts`)
│   ├── generated     # Type definitions generated by GraphQL Code Generator
│   ├── public        # Static files (`vite.svg`)
│   ├── src           # Frontend source code
│   ├── package.json  # Frontend dependencies
│   ├── vite.config.ts # `Vite` configuration file
Folder / File Description
config/codegen.ts GraphQL Code Generator configuration file
generated/graphql.ts Types generated by GraphQL Code Generator
public/ Static files (vite.svg)
src/ React (Vite) source code
src/App.tsx Main React component
src/service.ts GraphQL API communication logic
vite.config.ts Vite configuration file

packages/graphql/ (Shared GraphQL Package)

│   └── packages
│       └── graphql
│           ├── operations
│           │   └── getPost.graphql  # Query (`GetPost`)
│           └── schema
│               ├── post.graphql     # `Post` schema definition
│               └── recipe.graphql   # `Recipe` schema definition
Folder / File Description
operations/getPost.graphql GetPost query definition
schema/post.graphql GraphQL schema for Post (type Post { ... })
schema/recipe.graphql GraphQL schema for Recipe

Conclusion

In this article, we explained how to build a React + Express (GraphQL) application using Schema-Driven Development (SDD).
By using GraphQL, the API schema becomes explicit, making it easier to develop the backend and frontend smoothly.

In particular, by using GraphQL Code Generator to automatically generate TypeScript types, we achieved type-safe development.
This allows you to ensure data consistency between the frontend and backend while improving development efficiency.

✅ Summary

  • What is Schema-Driven Development (SDD)? → An approach where you define API specifications as schemas and develop based on them
  • Benefits → Enables parallel development of frontend and backend, and type-safe development
  • Tech stack → React (Vite), Express (Apollo Server), GraphQL
  • Automatically generating types from schemas → Using GraphQL Code Generator

By leveraging schema-driven development, you can flexibly handle API changes and write more maintainable code.

Please use this tutorial as a reference and try adopting SDD in your own projects.

The code implemented in this tutorial is stored in the following repository, so please refer to it as well.

https://github.com/shinagawa-web/schema-driven-development-react-express-graphql

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form