RPC Templates

RPC templates use Hono's built-in RPC client for seamless type inference from backend to frontend. No code generation needed - types flow automatically.

Available RPC Templates

Full Stack with RPC

Complete application with all features enabled.

npm create vinoflare@latest my-app --rpc

Features:

  • ✅ React frontend with TanStack Router
  • ✅ Hono RPC client with type inference
  • ✅ Cloudflare D1 database with Drizzle ORM
  • ✅ Better Auth with Discord OAuth
  • ✅ Zero code generation

RPC without Authentication

Perfect for public applications with data persistence.

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

Features:

  • ✅ React frontend with TanStack Router
  • ✅ Hono RPC client with type inference
  • ✅ Cloudflare D1 database with Drizzle ORM
  • ❌ No authentication
  • ✅ Public API endpoints

RPC without Database

Ideal for simple frontends with minimal backend logic.

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

Features:

  • ✅ React frontend with TanStack Router
  • ✅ Hono RPC client with type inference
  • ❌ No database
  • ❌ No authentication
  • ✅ Minimal configuration

How RPC Works

1. Define Backend Routes

// src/server/modules/todos/todos.routes.ts
import { Hono } from "hono";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
 
const todosApp = new Hono()
  .get("/", async (c) => {
    const todos = await db.select().from(todosTable);
    return c.json(todos);
  })
  .post(
    "/",
    zValidator(
      "json",
      z.object({
        title: z.string(),
        completed: z.boolean().default(false),
      })
    ),
    async (c) => {
      const body = c.req.valid("json");
      const todo = await db.insert(todosTable).values(body);
      return c.json(todo, 201);
    }
  )
  .delete("/:id", async (c) => {
    const id = c.req.param("id");
    await db.delete(todosTable).where(eq(todosTable.id, id));
    return c.json({ success: true });
  });
 
export type TodosApp = typeof todosApp;

2. Export App Type

// src/server/core/app-factory.ts
export function createApp() {
  const app = new Hono()
    .route("/api/todos", todosApp)
    .route("/api/users", usersApp);
    
  return app;
}
 
export type AppType = ReturnType<typeof createApp>;

3. Use in Frontend

// src/client/lib/rpc-client.ts
import { hc } from "hono/client";
import type { AppType } from "@/server/core/app-factory";
 
export const client = hc<AppType>("/");
// src/client/routes/todos.tsx
import { client } from "@/client/lib/rpc-client";
 
export function TodosPage() {
  const [todos, setTodos] = useState([]);
  
  useEffect(() => {
    loadTodos();
  }, []);
  
  const loadTodos = async () => {
    const res = await client.api.todos.$get();
    if (res.ok) {
      const data = await res.json();
      setTodos(data);
    }
  };
  
  const createTodo = async (title: string) => {
    const res = await client.api.todos.$post({
      json: { title, completed: false }
    });
    if (res.ok) {
      await loadTodos();
    }
  };
  
  return (
    // Your component JSX
  );
}

With TanStack Query

Setup Query Client

// src/client/lib/query-client.ts
import { QueryClient } from "@tanstack/react-query";
 
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
      refetchOnWindowFocus: false,
    },
  },
});

Create Custom Hooks

// src/client/hooks/use-todos.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/client/lib/rpc-client";
 
export function useTodos() {
  return useQuery({
    queryKey: ["todos"],
    queryFn: async () => {
      const res = await client.api.todos.$get();
      if (!res.ok) throw new Error("Failed to fetch todos");
      return res.json();
    },
  });
}
 
export function useCreateTodo() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (data: { title: string }) => {
      const res = await client.api.todos.$post({
        json: { ...data, completed: false }
      });
      if (!res.ok) throw new Error("Failed to create todo");
      return res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
}
 
export function useDeleteTodo() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (id: string) => {
      const res = await client.api.todos[":id"].$delete({
        param: { id }
      });
      if (!res.ok) throw new Error("Failed to delete todo");
      return res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
}

Use in Components

export function TodosList() {
  const { data: todos, isLoading } = useTodos();
  const createTodo = useCreateTodo();
  const deleteTodo = useDeleteTodo();
  
  if (isLoading) return <div>Loading...</div>;
  
  return (
    <div>
      <button
        onClick={() => createTodo.mutate({ title: "New Todo" })}
      >
        Add Todo
      </button>
      
      <ul>
        {todos?.map((todo) => (
          <li key={todo.id}>
            {todo.title}
            <button onClick={() => deleteTodo.mutate(todo.id)}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Project Structure

Advanced Features

Type-Safe Parameters

// Backend
const app = new Hono()
  .get("/search", 
    zValidator("query", z.object({
      q: z.string(),
      limit: z.number().optional().default(10),
    })),
    async (c) => {
      const { q, limit } = c.req.valid("query");
      // Search logic
    }
  );
 
// Frontend - TypeScript knows the query parameters!
const res = await client.api.search.$get({
  query: { q: "typescript", limit: 20 }
});

File Uploads

// Backend
.post("/upload",
  async (c) => {
    const body = await c.req.parseBody();
    const file = body.file as File;
    
    // Process file
    const url = await uploadToR2(file);
    
    return c.json({ url });
  }
);
 
// Frontend
const uploadFile = async (file: File) => {
  const formData = new FormData();
  formData.append("file", file);
  
  const res = await client.api.upload.$post({
    body: formData,
  });
  
  if (res.ok) {
    const { url } = await res.json();
    return url;
  }
};

Streaming Responses

// Backend
.get("/stream", (c) => {
  return c.streamText(async (stream) => {
    for (let i = 0; i < 10; i++) {
      await stream.write(`Data chunk ${i}\n`);
      await stream.sleep(1000);
    }
  });
});
 
// Frontend
const streamData = async () => {
  const res = await client.api.stream.$get();
  const reader = res.body?.getReader();
  
  while (reader) {
    const { done, value } = await reader.read();
    if (done) break;
    
    const text = new TextDecoder().decode(value);
    console.log(text);
  }
};

Authentication with RPC

// Backend auth middleware
const authMiddleware = async (c, next) => {
  const session = await auth.getSession(c);
  if (!session) {
    return c.json({ error: "Unauthorized" }, 401);
  }
  c.set("user", session.user);
  await next();
};
 
// Protected routes
const protectedApp = new Hono()
  .use("*", authMiddleware)
  .get("/profile", async (c) => {
    const user = c.get("user");
    return c.json(user);
  });
 
// Frontend
const getProfile = async () => {
  const res = await client.api.profile.$get();
  
  if (res.status === 401) {
    // Redirect to login
    window.location.href = "/login";
    return;
  }
  
  if (res.ok) {
    const profile = await res.json();
    return profile;
  }
};

Best Practices

1. Error Handling

// Create a typed error response
const errorResponse = (message: string, status = 400) => {
  return c.json({ error: message }, status);
};
 
// Use try-catch in handlers
.post("/", async (c) => {
  try {
    const data = c.req.valid("json");
    const result = await createTodo(data);
    return c.json(result);
  } catch (error) {
    return errorResponse("Failed to create todo", 500);
  }
});

2. Request Validation

// Define schemas
const createTodoSchema = z.object({
  title: z.string().min(1).max(100),
  description: z.string().optional(),
  dueDate: z.string().datetime().optional(),
});
 
// Use in routes
.post("/",
  zValidator("json", createTodoSchema),
  async (c) => {
    const validated = c.req.valid("json");
    // TypeScript knows the exact shape!
  }
);

3. Response Types

// Define response types
type ApiResponse<T> = {
  data: T;
  error?: string;
};
 
// Use consistently
.get("/", async (c) => {
  const todos = await getTodos();
  return c.json<ApiResponse<Todo[]>>({
    data: todos
  });
});

Performance Tips

1. Parallel Requests

const loadDashboard = async () => {
  const [todosRes, statsRes, recentRes] = await Promise.all([
    client.api.todos.$get(),
    client.api.stats.$get(),
    client.api.recent.$get(),
  ]);
  
  // Process responses
};

2. Request Deduplication

// Use React Query or SWR for automatic deduplication
const { data } = useQuery({
  queryKey: ["todos"],
  queryFn: async () => {
    const res = await client.api.todos.$get();
    return res.json();
  },
});

3. Optimistic Updates

const optimisticCreate = useMutation({
  mutationFn: createTodo,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries(["todos"]);
    
    const previous = queryClient.getQueryData(["todos"]);
    queryClient.setQueryData(["todos"], (old) => [
      ...old,
      { ...newTodo, id: "temp", createdAt: new Date() }
    ]);
    
    return { previous };
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(["todos"], context.previous);
  },
});

Troubleshooting

Types not updating?

  1. Restart TypeScript server
  2. Check for circular imports
  3. Ensure server types are exported
  4. Clear TypeScript cache

CORS issues?

RPC templates are configured for same-origin requests. For cross-origin:

// Add CORS middleware
app.use("*", cors({
  origin: "http://localhost:5173",
  credentials: true,
}));

Authentication not working?

  1. Check cookies are included: credentials: "include"
  2. Verify auth middleware is applied
  3. Check session configuration
  4. Ensure proper HTTPS in production

Next Steps