pyRPC
DemoBlogChangelogDocs
← Back to Blog

Building a full-stack app with pyRPC

·10 min read

In this tutorial, we'll build a complete full-stack application: a task management API with a FastAPI backend and a TypeScript React frontend — all connected through pyRPC with end-to-end type safety.

What we're building

A simple task manager. Users can list tasks, create new ones, toggle completion status, and delete tasks. The data lives in memory (for simplicity), but the patterns are the same for any database.

Step 1: Set up the backend

mkdir pyrpc-tasks
cd pyrpc-tasks
pip install pyrpc-core pyrpc-fastapi uvicorn
mkdir server

Create server/app.py:

from pyrpc_core import rpc, model
from pyrpc_fastapi import mount_fastapi
from fastapi import FastAPI
from dataclasses import dataclass
from typing import Optional

app = FastAPI()

@model
class Task:
    id: int
    title: str
    completed: bool = False

# In-memory store
_tasks: list[Task] = []
_next_id = 1

@rpc
def list_tasks() -> list[Task]:
    return list(_tasks)

@rpc
def create_task(title: str) -> Task:
    global _next_id
    task = Task(id=_next_id, title=title, completed=False)
    _tasks.append(task)
    _next_id += 1
    return task

@rpc
def toggle_task(task_id: int) -> Optional[Task]:
    for task in _tasks:
        if task.id == task_id:
            task.completed = not task.completed
            return task
    return None

@rpc
def delete_task(task_id: int) -> bool:
    global _tasks
    before = len(_tasks)
    _tasks = [t for t in _tasks if t.id != task_id]
    return len(_tasks) < before

mount_fastapi(app)

Each procedure is a plain Python function with type annotations. pyRPC handles serialization, validation, and routing automatically. Start the server:

uvicorn server.app:app --reload

Step 2: Generate the TypeScript types

npx pyrpc codegen --url http://localhost:8000 --out ./src/types

This generates the @pyrpc/types module with inferred types for Task, list_tasks, create_task, toggle_task, and delete_task.

Step 3: Set up the frontend

npx create-vite@latest frontend --template react-ts
cd frontend
npm install @pyrpc/client
npm run dev

Create src/client.ts:

import { createClient } from "@pyrpc/client"
import type { Types } from "./types"

export const client = createClient<Types>({
    url: "http://localhost:8000/pyrpc",
})

Now client is a fully typed proxy. Every procedure is available as a method with the correct parameters and return types.

Step 4: Build the UI

Here's the main App.tsx:

import { useEffect, useState } from "react"
import { client } from "./client"

interface Task {
    id: number
    title: string
    completed: boolean
}

export default function App() {
    const [tasks, setTasks] = useState<Task[]>([])
    const [title, setTitle] = useState("")

    const loadTasks = async () => {
        const result = await client.list_tasks()
        setTasks(result)
    }

    useEffect(() => { loadTasks() }, [])

    const handleCreate = async () => {
        if (!title.trim()) return
        await client.create_task(title.trim())
        setTitle("")
        await loadTasks()
    }

    const handleToggle = async (id: number) => {
        await client.toggle_task(id)
        await loadTasks()
    }

    const handleDelete = async (id: number) => {
        await client.delete_task(id)
        await loadTasks()
    }

    return (
        <div className="max-w-md mx-auto mt-10 p-6">
            <h1 className="text-xl font-bold mb-4">Tasks</h1>
            <div className="flex gap-2 mb-6">
                <input
                    value={title}
                    onChange={(e) => setTitle(e.target.value)}
                    onKeyDown={(e) => e.key === "Enter" && handleCreate()}
                    placeholder="New task..."
                    className="flex-1 border rounded px-3 py-2"
                />
                <button
                    onClick={handleCreate}
                    className="bg-blue-600 text-white px-4 py-2 rounded"
                >
                    Add
                </button>
            </div>
            <ul className="space-y-2">
                {tasks.map((task) => (
                    <li key={task.id} className="flex items-center gap-3">
                        <input
                            type="checkbox"
                            checked={task.completed}
                            onChange={() => handleToggle(task.id)}
                        />
                        <span className={task.completed ? "line-through" : ""}>
                            {task.title}
                        </span>
                        <button
                            onClick={() => handleDelete(task.id)}
                            className="ml-auto text-red-500 text-sm"
                        >
                            Delete
                        </button>
                    </li>
                ))}
            </ul>
        </div>
    )
}

Notice that every client.* call is fully typed. If you change the return type of list_tasks in Python, TypeScript will flag the mismatch immediately. There's no API response to reverse-engineer, no OpenAPI spec to consult — the types are the truth.

What this looks like in practice

When you type client., your editor shows autocomplete for all four procedures: list_tasks, create_task, toggle_task, delete_task. Each one shows the expected parameters and return type. If you pass a string where a number is expected, TypeScript gives you a red squiggly line — before you ever run the code.

This is the same experience you'd get with tRPC on a TypeScript backend, but now your backend is Python. The type bridge works in both directions: the Python functions are validated with Pydantic, and the TypeScript client mirrors those constraints at compile time.

Next steps

From here, you can:

  • Add a database (SQLAlchemy, Prisma, etc.) behind the procedures
  • Add authentication with a middleware or decorator
  • Switch to Flask by changing one import
  • Deploy with Docker — the FastAPI app is a standard ASGI application

The full source code for this tutorial is available in the examples directory.