Orval Templates

Orval templates use OpenAPI specifications to generate fully typed API clients with React Query hooks. This approach provides excellent documentation, type safety, and developer experience.

Available Orval Templates

Full Stack with Orval

Complete application with all features enabled.

npm create vinoflare@latest my-app

Features:

  • ✅ React frontend with TanStack Router
  • ✅ Orval API client generation
  • ✅ Cloudflare D1 database with Drizzle ORM
  • ✅ Better Auth with Discord OAuth
  • ✅ Full type safety across the stack

Orval without Authentication

Perfect for public-facing applications that need data persistence.

npm create vinoflare@latest my-app --no-auth

Features:

  • ✅ React frontend with TanStack Router
  • ✅ Orval API client generation
  • ✅ Cloudflare D1 database with Drizzle ORM
  • ❌ No authentication
  • ✅ Public API endpoints

Orval without Database

Ideal for stateless frontends that interact with external APIs.

npm create vinoflare@latest my-app --no-db

Features:

  • ✅ React frontend with TanStack Router
  • ✅ Orval API client generation
  • ❌ No database
  • ❌ No authentication
  • ✅ Lightweight and fast

How Orval Works

1. OpenAPI Schema

Your API endpoints are documented in an OpenAPI schema:

# openapi.yaml
paths:
  /api/todos:
    get:
      operationId: getTodos
      responses:
        '200':
          description: List of todos
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Todo'

2. Code Generation

Orval generates typed hooks and client functions:

npm run gen:api

This creates:

  • Typed API client functions
  • React Query hooks
  • TypeScript interfaces
  • Request/response types

3. Using Generated Hooks

import { useGetTodos, useCreateTodo } from "@/generated/endpoints";
 
function TodoList() {
  // Fully typed query hook
  const { data: todos, isLoading } = useGetTodos();
  
  // Fully typed mutation hook
  const createTodo = useCreateTodo();
  
  const handleCreate = () => {
    createTodo.mutate({
      title: "New Todo",
      completed: false
    });
  };
  
  return (
    // Your component JSX
  );
}

Project Structure

Configuration

Orval Config

The orval.config.ts file controls code generation:

export default {
  api: {
    input: {
      target: "./src/generated/openapi.json",
    },
    output: {
      target: "./src/generated/endpoints",
      client: "react-query",
      override: {
        mutator: {
          path: "./src/client/lib/custom-fetch.ts",
          name: "customFetch",
        },
      },
    },
  },
};

Custom Fetch

Configure authentication and base URL:

// src/client/lib/custom-fetch.ts
export const customFetch = async <T>(
  url: string,
  options?: RequestInit
): Promise<T> => {
  const response = await fetch(url, {
    ...options,
    credentials: "include", // Include cookies
    headers: {
      "Content-Type": "application/json",
      ...options?.headers,
    },
  });
 
  if (!response.ok) {
    throw new Error(`API Error: ${response.statusText}`);
  }
 
  return response.json();
};

Development Workflow

1. Define API Routes

// src/server/modules/todos/todos.routes.ts
import { createRouter } from "@/server/core/api-builder";
import { z } from "zod";
 
const todoSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean(),
});
 
export const todosRouter = createRouter()
  .get("/", {
    responses: {
      200: {
        description: "List of todos",
        schema: z.array(todoSchema),
      },
    },
    handler: async (c) => {
      const todos = await db.select().from(todosTable);
      return c.json(todos);
    },
  })
  .post("/", {
    body: todoSchema.omit({ id: true }),
    responses: {
      201: {
        description: "Created todo",
        schema: todoSchema,
      },
    },
    handler: async (c) => {
      const body = await c.req.valid("json");
      const todo = await db.insert(todosTable).values(body);
      return c.json(todo, 201);
    },
  });

2. Generate OpenAPI Schema

npm run gen:openapi

3. Generate Client Code

npm run gen:api

4. Use in Frontend

import { useGetTodos } from "@/generated/endpoints";
 
export function TodosPage() {
  const { data: todos, isLoading, error } = useGetTodos();
 
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
 
  return (
    <ul>
      {todos?.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Best Practices

1. API Design

  • Follow RESTful conventions
  • Use consistent naming
  • Document all endpoints
  • Version your API

2. Schema Management

  • Keep schemas DRY with $ref
  • Use meaningful operation IDs
  • Document response types
  • Validate request bodies

3. Error Handling

const { data, error, isLoading } = useGetTodos({
  query: {
    onError: (error) => {
      console.error("Failed to fetch todos:", error);
      toast.error("Could not load todos");
    },
  },
});

4. Optimistic Updates

const queryClient = useQueryClient();
const createTodo = useCreateTodo({
  mutation: {
    onMutate: async (newTodo) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ["getTodos"] });
      
      // Snapshot previous value
      const previousTodos = queryClient.getQueryData(["getTodos"]);
      
      // Optimistically update
      queryClient.setQueryData(["getTodos"], (old) => [
        ...old,
        { ...newTodo, id: "temp-id" },
      ]);
      
      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      // Rollback on error
      queryClient.setQueryData(["getTodos"], context.previousTodos);
    },
    onSettled: () => {
      // Always refetch after error or success
      queryClient.invalidateQueries({ queryKey: ["getTodos"] });
    },
  },
});

Common Patterns

Pagination

// API endpoint
.get("/", {
  query: z.object({
    page: z.number().default(1),
    limit: z.number().default(10),
  }),
  handler: async (c) => {
    const { page, limit } = c.req.valid("query");
    const offset = (page - 1) * limit;
    
    const todos = await db
      .select()
      .from(todosTable)
      .limit(limit)
      .offset(offset);
      
    return c.json({
      data: todos,
      page,
      limit,
      total: await db.count(todosTable),
    });
  },
})
 
// Frontend usage
const { data } = useGetTodos({
  page: currentPage,
  limit: 10,
});

Search and Filtering

// API endpoint
.get("/", {
  query: z.object({
    search: z.string().optional(),
    status: z.enum(["all", "active", "completed"]).default("all"),
  }),
  handler: async (c) => {
    const { search, status } = c.req.valid("query");
    
    let query = db.select().from(todosTable);
    
    if (search) {
      query = query.where(like(todosTable.title, `%${search}%`));
    }
    
    if (status !== "all") {
      query = query.where(
        eq(todosTable.completed, status === "completed")
      );
    }
    
    return c.json(await query);
  },
})

Troubleshooting

Generated types not updating?

  1. Check that OpenAPI schema is generated: npm run gen:openapi
  2. Regenerate client code: npm run gen:api
  3. Restart TypeScript server in your editor

API calls failing?

  1. Check browser DevTools Network tab
  2. Verify API is running: npm run dev
  3. Check CORS settings if cross-origin
  4. Ensure authentication cookies are included

Type errors in generated code?

  1. Ensure OpenAPI schema is valid
  2. Check for circular references
  3. Verify all schemas are properly defined
  4. Update Orval to latest version

Next Steps