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
- Django / Flask with Jinja2 templates: Server-side rendering with scattered jQuery or vanilla JS for interactivity.
- Streamlit: Rapid prototyping but limited in production scalability and customization.
- Dash / Plotly Dash: Callback-based architecture that becomes unwieldy at scale.
- Older React/Vue apps with separate Python backends: Maintaining two codebases, two deployment pipelines, and REST/graphql glue code.
What Reflex Brings to the Table
- Pure Python: Frontend and backend logic live in one Python codebase.
- Reactive State: UI updates automatically when state variables change—no manual DOM manipulation.
- Component Model: Build reusable UI pieces as Python classes.
- Built-in Backend: FastAPI under the hood, with WebSocket-based real-time updates.
- Deployment Simplicity: Single command deployment to Reflex Cloud or any Docker-capable environment.
Why Migration Matters
Staying on a legacy framework isn't just about familiarity—it has real costs:
- Developer productivity: Context-switching between Python, JavaScript, HTML, and CSS slows iteration cycles.
- Onboarding friction: New team members must learn multiple technologies before becoming productive.
- State management complexity: Hand-rolled state synchronization between frontend and backend breeds bugs.
- Modern expectations: Users expect real-time updates, responsive UIs, and smooth interactions that legacy server-rendered apps struggle to deliver.
- Maintenance burden: Old dependency trees, deprecated packages, and security vulnerabilities accumulate.
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:
- Every route/view in the application
- All forms and user input mechanisms
- State that persists across requests (session data, database records)
- Real-time or WebSocket-dependent features
- Third-party integrations (APIs, payment gateways, authentication providers)
- Custom CSS or styling requirements
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:
- Deploy Reflex on a subdomain or separate path (e.g.,
/reflex/) while the legacy app continues to serve the main domain. - Route a subset of traffic to the new Reflex pages using a reverse proxy (Nginx, Traefik, or a cloud load balancer).
- Share authentication by issuing JWTs from the legacy app that Reflex validates, or use a shared session store like Redis.
- Migrate one feature at a time, validating each with automated tests before moving on.
- 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
- Mutating state directly: Always use immutable updates (create new lists/dicts rather than appending to existing ones) to ensure Reflex detects changes and triggers re-renders.
- Overloading a single state class: Split state into focused classes (e.g.,
AuthState,CartState,ProductState) rather than one monolithic class. This improves performance and maintainability. - Ignoring the existing database: Don't assume you must migrate your database. Reflex works with any SQLAlchemy-compatible database, so your existing PostgreSQL, MySQL, or SQLite database can be used as-is.
- Skipping the assessment phase: Teams that jump straight to coding often miss hidden features buried in legacy templates or middleware. The cataloging step is essential.
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.