← Back to DevBytes

Migrating from Legacy Frameworks to Reflex

Introduction: What Is Reflex and Why Migrate?

Reflex is an open-source, full-stack web framework that lets you build modern web applications entirely in Python. It compiles your Python code to a React frontend and runs a FastAPI backend, eliminating the need to write JavaScript, HTML templates, or CSS separately. This stands in stark contrast to legacy frameworks like Django, Flask, Streamlit, or even older architectures built on jQuery and server-rendered templates.

Legacy frameworks served their purpose well, but they often come with accumulated technical debt: scattered frontend logic, brittle template systems, outdated state management, and the constant context-switching between Python and JavaScript. Reflex addresses these pain points by unifying the development experience under a single language and a reactive, component-based model.

This tutorial walks you through the process of migrating a typical legacy web application to Reflex, covering assessment, incremental migration strategies, code translation patterns, and best practices to ensure a smooth transition.

Understanding the Legacy Landscape

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Common Legacy Frameworks

What Reflex Brings to the Table

Why Migration Matters

Staying on a legacy framework isn't just about familiarity—it has real costs:

Migrating to Reflex consolidates your stack, reduces code surface area, and aligns your application with modern reactive patterns—all while keeping everything in Python.

Pre-Migration Assessment

Catalog Your Application's Features

Before writing any Reflex code, audit your existing application. Create a spreadsheet or document listing:

This inventory will guide your migration plan and prevent feature regressions.

Identify Migration Boundaries

You don't need to migrate everything at once. Identify natural seams where Reflex components can replace legacy pieces incrementally. For example, a Django application might have distinct sections like a public dashboard, an admin panel, and a reporting module—each of which can be migrated independently.

Setting Up Your Reflex Environment

Start by installing Reflex in a fresh virtual environment alongside your existing project:

pip install reflex
reflex init

This creates a project directory with a standard Reflex structure:

myapp/
├── .web/
├── myapp/
│   ├── __init__.py
│   └── myapp.py
├── assets/
├── rxconfig.py
└── requirements.txt

The myapp/myapp.py file is where your application logic lives. The rxconfig.py file holds configuration like database URLs, API keys, and environment-specific settings.

Step-by-Step Migration Patterns

Step 1: Translate Routes to Reflex Pages

In a legacy Flask or Django app, each URL route maps to a view function that renders an HTML template. In Reflex, routes are defined by creating functions that return Reflex components and decorating them or assigning them to pages.

Legacy Flask example:

# Legacy Flask route
@app.route('/dashboard')
def dashboard():
    user = get_current_user()
    stats = fetch_stats(user.id)
    return render_template('dashboard.html', user=user, stats=stats)

Reflex equivalent:

import reflex as rx
from myapp.state import AppState

def dashboard() -> rx.Component:
    return rx.box(
        rx.heading(f"Welcome, {AppState.user.name}"),
        rx.grid(
            rx.stat_card("Total Orders", AppState.stats.total_orders),
            rx.stat_card("Revenue", AppState.stats.revenue),
            columns="2",
        ),
        padding="2rem",
    )

# In your main app, assign the page
app = rx.App()
app.add_page(dashboard, route="/dashboard")

Step 2: Convert Stateful Logic to Reflex State Classes

Legacy frameworks often store session state in server-side sessions or pass it through template contexts. Reflex centralizes state in classes that inherit from rx.State. These state classes are persistent per-user and automatically trigger UI re-renders when modified.

Legacy Django view with session state:

def add_to_cart(request):
    product_id = request.POST.get('product_id')
    cart = request.session.get('cart', [])
    cart.append(product_id)
    request.session['cart'] = cart
    return redirect('cart_page')

Reflex state class:

class CartState(rx.State):
    items: list[str] = []
    
    def add_item(self, product_id: str):
        self.items = self.items + [product_id]  # Immutable update triggers reactivity
        
    @rx.var
    def item_count(self) -> int:
        return len(self.items)

Now any component bound to CartState.items or CartState.item_count will automatically update when add_item is called.

Step 3: Replace Form Handling

Legacy forms typically POST to a server endpoint, which validates input, processes it, and redirects. Reflex forms bind directly to state and handle validation inline.

Legacy Django form:

# forms.py
class ContactForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    message = forms.CharField(widget=forms.Textarea)

# views.py
def contact_view(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)
        if form.is_valid():
            send_email(form.cleaned_data)
            return redirect('thanks')
    else:
        form = ContactForm()
    return render(request, 'contact.html', {'form': form})

Reflex form:

class ContactState(rx.State):
    name: str = ""
    email: str = ""
    message: str = ""
    submitted: bool = False
    
    def handle_submit(self, form_data: dict):
        self.name = form_data.get("name", "")
        self.email = form_data.get("email", "")
        self.message = form_data.get("message", "")
        # Call your email sending logic here
        self.submitted = True
    
    def reset_form(self):
        self.name = ""
        self.email = ""
        self.message = ""
        self.submitted = False

def contact_form() -> rx.Component:
    return rx.form(
        rx.vstack(
            rx.input(placeholder="Name", name="name"),
            rx.input(placeholder="Email", name="email", type="email"),
            rx.textarea(placeholder="Your message", name="message"),
            rx.button("Send", type="submit"),
            rx.cond(
                ContactState.submitted,
                rx.text("Thank you! Your message has been sent.", color="green"),
                rx.text(""),
            ),
            spacing="1rem",
        ),
        on_submit=ContactState.handle_submit,
        reset_on_submit=True,
    )

Step 4: Migrate Authentication and Authorization

Authentication in legacy frameworks often relies on middleware, decorators, and session cookies. Reflex handles auth through state classes and event handlers that gate access to pages.

class AuthState(rx.State):
    is_authenticated: bool = False
    username: str = ""
    
    def login(self, username: str, password: str):
        # Verify credentials against your existing user database
        if verify_credentials(username, password):
            self.is_authenticated = True
            self.username = username
            return rx.redirect("/dashboard")
        return rx.window_alert("Invalid credentials")
    
    def logout(self):
        self.is_authenticated = False
        self.username = ""
        return rx.redirect("/login")

def protected_page() -> rx.Component:
    return rx.cond(
        AuthState.is_authenticated,
        rx.box(
            rx.heading(f"Welcome, {AuthState.username}"),
            rx.button("Logout", on_click=AuthState.logout),
            # Protected content here
        ),
        rx.box(
            rx.heading("Access Denied"),
            rx.link("Go to Login", href="/login"),
        ),
    )

For more sophisticated auth, Reflex integrates with libraries like fastapi-users or can leverage JWT tokens stored in state.

Step 5: Convert Database Interactions

Legacy apps often use Django ORM or SQLAlchemy directly in view functions. Reflex works beautifully with SQLAlchemy and includes built-in ORM utilities. You can bring your existing models over with minimal changes.

# Legacy SQLAlchemy model
from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class Product(Base):
    __tablename__ = "products"
    id = Column(Integer, primary_key=True)
    name = Column(String)
    price = Column(Integer)

Reflex integration:

import reflex as rx
from sqlmodel import Field, Session, select
from typing import Optional

class Product(rx.Model, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str
    price: int

class ProductState(rx.State):
    products: list[Product] = []
    
    def load_products(self):
        with rx.session() as session:
            self.products = session.exec(select(Product)).all()
    
    def add_product(self, name: str, price: int):
        with rx.session() as session:
            product = Product(name=name, price=price)
            session.add(product)
            session.commit()
        self.load_products()  # Refresh the list

Reflex uses sqlmodel (a layer over SQLAlchemy) and provides rx.session() context managers that handle connection pooling automatically.

Step 6: Translate Charts and Data Visualizations

If your legacy app uses Matplotlib, Plotly, or Bokeh for charts, Reflex offers a clean wrapper around Recharts (a React charting library) that you control entirely from Python.

def revenue_chart() -> rx.Component:
    return rx.recharts.bar_chart(
        rx.recharts.bar(
            rx.recharts.cartesian_grid(stroke_dasharray="3 3"),
            rx.recharts.x_axis(data_key="month"),
            rx.recharts.y_axis(),
            data=AnalyticsState.monthly_revenue,
            data_key="revenue",
            fill="#8884d8",
        ),
        width=600,
        height=400,
    )

Your existing data processing logic stays identical; only the rendering layer changes.

Step 7: Handle WebSocket and Real-Time Features

Legacy real-time features often require separate infrastructure like Socket.IO, Django Channels, or Pusher. Reflex provides real-time updates out of the box: any state change automatically pushes updates to connected clients via WebSocket. No additional infrastructure is needed.

class ChatState(rx.State):
    messages: list[dict] = []
    current_message: str = ""
    
    def send_message(self):
        self.messages = self.messages + [{
            "user": self.username,
            "text": self.current_message,
            "timestamp": datetime.now().isoformat(),
        }]
        self.current_message = ""
        # All connected clients see the new message instantly

Incremental Migration Strategy: The Strangler Fig Pattern

Rather than a risky big-bang rewrite, use the strangler fig pattern: run Reflex alongside your legacy application and gradually replace pieces. Here's how:

  1. Deploy Reflex on a subdomain or separate path (e.g., /reflex/) while the legacy app continues to serve the main domain.
  2. Route a subset of traffic to the new Reflex pages using a reverse proxy (Nginx, Traefik, or a cloud load balancer).
  3. Share authentication by issuing JWTs from the legacy app that Reflex validates, or use a shared session store like Redis.
  4. Migrate one feature at a time, validating each with automated tests before moving on.
  5. Decommission legacy routes once their Reflex counterparts are stable and traffic has been fully shifted.

Testing During Migration

Reflex components can be tested using standard Python testing frameworks. Write tests for your state classes first—they contain the core business logic:

def test_cart_state():
    state = CartState()
    assert state.item_count == 0
    state.add_item("product-123")
    assert state.item_count == 1
    assert "product-123" in state.items

For end-to-end testing, Reflex ships with a built-in testing harness that spins up a headless browser. Use it to verify critical user journeys:

def test_login_flow():
    with rx.AppHarness.create(root="myapp") as harness:
        assert harness.page.locator("text=Login").is_visible()
        harness.page.fill("input[name='username']", "testuser")
        harness.page.fill("input[name='password']", "password123")
        harness.page.click("button[type='submit']")
        harness.page.wait_for_selector("text=Welcome")

Best Practices for a Smooth Migration

Keep Business Logic Separate from Presentation

Extract your core business logic into plain Python functions that don't depend on any framework. These can be reused directly in Reflex state classes:

# Pure business logic, framework-agnostic
def calculate_discount(cart_total: float, customer_tier: str) -> float:
    if customer_tier == "premium":
        return cart_total * 0.15
    return cart_total * 0.05

# Reflex state just calls it
class CheckoutState(rx.State):
    cart_total: float = 0
    customer_tier: str = "standard"
    
    @rx.var
    def discount_amount(self) -> float:
        return calculate_discount(self.cart_total, self.customer_tier)

Leverage Reflex's Built-in Components

Resist the temptation to rebuild everything from scratch. Reflex ships with a rich component library covering layouts, forms, navigation, data display, and more. Use rx.box, rx.vstack, rx.hstack, rx.grid, rx.table, and other primitives to compose your UI quickly.

Use Environment Variables for Configuration

Keep your rxconfig.py clean by reading sensitive values from environment variables:

import os
import reflex as rx

config = rx.Config(
    app_name="myapp",
    db_url=os.environ.get("DATABASE_URL", "sqlite:///default.db"),
    api_key=os.environ.get("API_KEY"),
)

Plan for SEO and Static Content

If your legacy app relied heavily on server-rendered HTML for SEO, be aware that Reflex renders client-side React. For public-facing content that needs indexing, consider using Reflex's server-side rendering capabilities or maintaining a lightweight static landing page alongside your Reflex app.

Monitor Performance After Migration

Reflex applications are generally performant, but the real-time WebSocket model behaves differently than traditional request-response cycles. Monitor WebSocket connection counts, state update frequency, and memory usage in production. Use Reflex's built-in telemetry or integrate with your existing observability stack.

Document the Migration for Your Team

Create a living document that maps legacy routes to new Reflex pages, legacy state mechanisms to Reflex state classes, and legacy templates to Reflex components. This reference will accelerate onboarding for team members joining mid-migration.

Common Pitfalls and How to Avoid Them

Conclusion

Migrating from a legacy framework to Reflex is a strategic investment in developer experience, code maintainability, and application responsiveness. By consolidating frontend and backend logic into pure Python, you eliminate the friction of multi-language web development while gaining reactive, real-time capabilities that modern users expect.

The migration itself doesn't need to be a disruptive rewrite. By following the strangler fig pattern, translating routes, state, forms, and database interactions piece by piece, and adhering to the best practices outlined here, you can incrementally modernize your application while keeping it running in production throughout the transition. The end result is a cleaner, more coherent codebase that your team can iterate on with confidence for years to come.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles