← Back to DevBytes

Migrating from Legacy Frameworks to NiceGUI

What Is NiceGUI and Why Migrate From Legacy Frameworks?

NiceGUI is a modern Python web UI framework that lets you build reactive, real-time web interfaces entirely in Python — no HTML, CSS, or JavaScript required unless you want to go deeper. It runs on top of FastAPI and Vue.js/Quasar under the hood, giving you a production-grade async web server with automatic WebSocket-based reactivity. Legacy frameworks — think Flask with Jinja2, Django Templates, Tkinter, PyQt/PySide, wxPython, or even older Streamlit patterns — often lock you into request-response cycles, manual DOM manipulation, or desktop-only deployment models. Migrating to NiceGUI means consolidating your UI logic into a single Python codebase that runs in the browser, supports real-time updates out of the box, and can be deployed on the web, on-premises, or even as a native desktop app via PyInstaller.

Key Advantages Over Legacy Approaches

Understanding the Migration Mindset

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

Before diving into code, it's important to recognize the paradigm shift. Legacy frameworks often follow one of these patterns:

NiceGUI flips the model: you write Python code that defines UI elements and their behavior, and the framework handles the rest — rendering, state synchronization, and real-time communication. The mental shift is from "handle request → render template → respond" or "bind event → update widget" to "compose reactive UI elements that stay in sync with your Python state."

Step-by-Step Migration Guide

1. From Flask + Jinja2 to NiceGUI

Consider a typical Flask application that displays a list of tasks and lets you add new ones via a form POST:

# legacy_flask_app.py
from flask import Flask, render_template, request, redirect, url_for

app = Flask(__name__)
tasks = ["Buy groceries", "Walk the dog"]

@app.route("/")
def index():
    return render_template("index.html", tasks=tasks)

@app.route("/add", methods=["POST"])
def add_task():
    task = request.form.get("task")
    if task:
        tasks.append(task)
    return redirect(url_for("index"))

if __name__ == "__main__":
    app.run(debug=True)

With the accompanying Jinja2 template index.html:

<!-- templates/index.html -->
<!DOCTYPE html>
<html>
<head><title>Task List</title></head>
<body>
  <h1>Tasks</h1>
  <ul>
    {% for task in tasks %}
      <li>{{ task }}</li>
    {% endfor %}
  </ul>
  <form method="POST" action="/add">
    <input name="task" placeholder="New task">
    <button type="submit">Add</button>
  </form>
</body>
</html>

Here's the same application migrated to NiceGUI. Notice how everything collapses into a single Python file, and the UI updates instantly without page reloads:

# nicegui_app.py
from nicegui import ui

tasks = ["Buy groceries", "Walk the dog"]

def add_task():
    task = new_task_input.value
    if task:
        tasks.append(task)
        task_list.clear()
        with task_list:
            for t in tasks:
                ui.label(t)
        new_task_input.value = ""  # Clear the input field

# Build the UI declaratively
with ui.column():
    ui.label("Tasks").classes("text-h4 text-bold")
    task_list = ui.column()
    # Initial rendering of tasks
    with task_list:
        for t in tasks:
            ui.label(t)
    
    new_task_input = ui.input(placeholder="New task", on_change=lambda e: None)
    ui.button("Add", on_click=add_task, icon="add")

ui.run(title="Task List", port=8080)

The migration eliminates the route definitions, template file, and redirect logic. The add_task callback directly manipulates the UI elements bound to the list, and NiceGUI handles the WebSocket update automatically.

2. From Tkinter Desktop Apps to NiceGUI

Tkinter apps are event-driven but confined to the desktop. Here's a simple Tkinter counter application:

# legacy_tkinter_app.py
import tkinter as tk

counter = 0

def increment():
    global counter
    counter += 1
    label.config(text=f"Count: {counter}")

def decrement():
    global counter
    counter -= 1
    label.config(text=f"Count: {counter}")

root = tk.Tk()
root.title("Counter")
root.geometry("300x200")

label = tk.Label(root, text=f"Count: {counter}", font=("Arial", 24))
label.pack(pady=20)

btn_frame = tk.Frame(root)
btn_frame.pack()

tk.Button(btn_frame, text="-", command=decrement, width=8).pack(side=tk.LEFT, padx=5)
tk.Button(btn_frame, text="+", command=increment, width=8).pack(side=tk.LEFT, padx=5)

root.mainloop()

Migrating this to NiceGUI gives you the same reactive counter, but now it runs in a browser — accessible locally or remotely — with a cleaner, more maintainable structure:

# nicegui_counter.py
from nicegui import ui

counter = {"value": 0}  # Use a dict for mutable state in closures

def increment():
    counter["value"] += 1
    count_label.set_text(f"Count: {counter['value']}")

def decrement():
    counter["value"] -= 1
    count_label.set_text(f"Count: {counter['value']}")

with ui.card().classes("mx-auto w-96"):
    ui.label("Counter Demo").classes("text-h4 text-center")
    count_label = ui.label(f"Count: {counter['value']}").classes("text-h3 text-center")
    
    with ui.row().classes("justify-center gap-4"):
        ui.button("-", on_click=decrement, icon="remove").props("flat round")
        ui.button("+", on_click=increment, icon="add").props("flat round")

ui.run(title="Counter", port=8080)

The migration path here is straightforward: replace widget constructors with NiceGUI element factories, replace command= callbacks with on_click=, and replace manual .config(text=...) updates with .set_text() or direct property assignments. The result is instantly web-deployable.

3. From Django Templates to NiceGUI Dashboards

Django excels at database-backed CRUD applications, but its template-based rendering can feel sluggish for interactive dashboards. Here's a simplified Django view pattern:

# legacy_django_view.py (conceptual)
from django.shortcuts import render
from .models import SensorReading

def dashboard(request):
    readings = SensorReading.objects.all().order_by("-timestamp")[:20]
    latest_temp = readings[0].temperature if readings else None
    context = {
        "readings": readings,
        "latest_temp": latest_temp,
    }
    return render(request, "dashboard.html", context)

In NiceGUI, you can build the same dashboard with live-updating charts and auto-refresh capabilities — while still using Django as the data layer via its ORM or a REST API:

# nicegui_dashboard.py
from nicegui import ui
import httpx  # or use Django's ORM directly if running in-process
import asyncio

sensor_data = []

async def fetch_readings():
    """Fetch sensor data from Django REST endpoint (or query ORM directly)."""
    async with httpx.AsyncClient() as client:
        # Example: calling a Django REST API endpoint
        response = await client.get("http://localhost:8000/api/readings/")
        return response.json()

async def update_dashboard():
    """Periodically refresh the dashboard data."""
    global sensor_data
    data = await fetch_readings()
    sensor_data = data
    
    # Update the UI elements
    readings_list.clear()
    with readings_list:
        for reading in data[-10:]:
            ui.label(f"{reading['timestamp']}: {reading['temperature']}°C")
    
    if data:
        latest_temp_label.set_text(f"Latest: {data[0]['temperature']}°C")

# Build the dashboard UI
with ui.column().classes("w-full max-w-3xl mx-auto"):
    ui.label("Sensor Dashboard").classes("text-h3 text-bold")
    latest_temp_label = ui.label("Latest: --°C").classes("text-h4")
    
    readings_list = ui.column().classes("gap-2")
    with readings_list:
        ui.label("Loading...")
    
    ui.button("Refresh Now", on_click=update_dashboard, icon="refresh")
    
    # Auto-refresh every 10 seconds using NiceGUI's timer
    ui.timer(10.0, update_dashboard)

# Trigger initial load
ui.run(port=8080, title="Sensor Dashboard")

This approach lets you keep Django for its strengths (ORM, admin, authentication) while using NiceGUI for the interactive frontend. You can even embed NiceGUI inside an existing FastAPI app that sits alongside Django.

4. Migrating Multi-Page Flask Apps to NiceGUI Pages

NiceGUI supports automatic page routing using ui.page() decorators, making it easy to migrate multi-route Flask applications:

# legacy_flask_multipage.py
from flask import Flask, render_template

app = Flask(__name__)

@app.route("/")
def home():
    return render_template("home.html")

@app.route("/about")
def about():
    return render_template("about.html")

@app.route("/settings")
def settings():
    return render_template("settings.html")

The NiceGUI equivalent uses the @ui.page() decorator to define each page as a builder function:

# nicegui_multipage.py
from nicegui import ui

@ui.page("/")
def home_page():
    ui.label("Welcome to the Home Page").classes("text-h3")
    ui.link("About", "/about")
    ui.link("Settings", "/settings")
    
    with ui.card():
        ui.label("This is the home page content")
        ui.button("Click me", on_click=lambda: ui.notify("Hello from home!"))

@ui.page("/about")
def about_page():
    ui.label("About Us").classes("text-h3")
    ui.link("Home", "/")
    ui.link("Settings", "/settings")
    
    ui.markdown("""
    ## Our Story
    We built this app to demonstrate NiceGUI's page routing.
    """)

@ui.page("/settings")
def settings_page():
    ui.label("Settings").classes("text-h3")
    ui.link("Home", "/")
    ui.link("About", "/about")
    
    dark_mode = ui.switch("Dark Mode")
    notifications = ui.switch("Enable Notifications")
    
    def save_settings():
        ui.notify(f"Dark mode: {dark_mode.value}, Notifications: {notifications.value}")
    
    ui.button("Save", on_click=save_settings)

ui.run(title="Multi-Page App", port=8080)

Each page builder function receives optional arguments for path parameters and query strings, making it easy to handle dynamic routes like /user/{user_id}.

Advanced Migration Patterns

State Management: From Global Variables to Reactive State

Legacy apps often rely on global variables or module-level state. In NiceGUI, you can use ui.state() or simple closures with mutable containers (dicts, lists, objects) to manage state cleanly. For more complex applications, consider using ui.context or integrating a proper state management library:

# Reactive state example with dataclass
from dataclasses import dataclass, field
from nicegui import ui

@dataclass
class AppState:
    user_name: str = "Guest"
    items: list = field(default_factory=list)
    dark_mode: bool = False

state = AppState()

def toggle_dark_mode():
    state.dark_mode = not state.dark_mode
    ui.notify(f"Dark mode: {state.dark_mode}")

with ui.card():
    ui.label(f"Welcome, {state.user_name}")
    ui.switch("Dark Mode", value=state.dark_mode, on_change=lambda e: setattr(state, 'dark_mode', e.value))
    ui.button("Toggle Dark Mode", on_click=toggle_dark_mode)

Database Integration: Keeping Your Data Layer Intact

One of the biggest concerns during migration is whether you need to rewrite your database layer. The answer is: you almost never do. NiceGUI runs standard Python, so you can use SQLAlchemy, Django ORM, Peewee, or even raw sqlite3 directly. Here's an example integrating SQLAlchemy with async database operations in a NiceGUI app:

# nicegui_with_sqlalchemy.py
from nicegui import ui
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import declarative_base, sessionmaker
import asyncio

Base = declarative_base()

class Note(Base):
    __tablename__ = "notes"
    id = Column(Integer, primary_key=True)
    content = Column(String)

# Setup database
engine = create_engine("sqlite:///notes.db")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)

def get_notes():
    session = Session()
    try:
        return session.query(Note).all()
    finally:
        session.close()

def add_note(content: str):
    session = Session()
    try:
        session.add(Note(content=content))
        session.commit()
    finally:
        session.close()

# Build UI
notes_container = ui.column()

def refresh_notes():
    notes_container.clear()
    with notes_container:
        for note in get_notes():
            with ui.card().classes("w-full"):
                ui.label(note.content)
                ui.label(f"ID: {note.id}").classes("text-caption")

def on_add():
    content = note_input.value
    if content:
        add_note(content)
        note_input.value = ""
        refresh_notes()
        ui.notify("Note added!", type="positive")

with ui.column().classes("w-full max-w-xl mx-auto gap-4"):
    ui.label("Notes").classes("text-h3")
    note_input = ui.textarea(placeholder="Write your note here...")
    ui.button("Add Note", on_click=on_add, icon="add")
    notes_container

# Initial load
refresh_notes()

ui.run(port=8080)

Handling Long-Running Tasks and Background Jobs

Legacy frameworks often require Celery, background threads, or task queues for long operations. NiceGUI provides built-in async support and ui.timer(), plus you can leverage asyncio.create_task() for background work without blocking the UI:

# Background task example
from nicegui import ui
import asyncio

progress_value = 0

async def long_running_task():
    """Simulate a slow operation with progress updates."""
    global progress_value
    for i in range(1, 101):
        await asyncio.sleep(0.05)  # Simulate work
        progress_value = i
        progress_bar.set_value(i / 100)
        status_label.set_text(f"Processing... {i}%")
    status_label.set_text("Complete!")
    ui.notify("Task finished successfully!", type="positive")

def start_task():
    # Launch as background task — UI remains responsive
    asyncio.create_task(long_running_task())

with ui.card().classes("w-96 mx-auto"):
    ui.label("Async Task Demo").classes("text-h4")
    status_label = ui.label("Ready")
    progress_bar = ui.linear_progress(value=0).classes("w-full")
    ui.button("Start Task", on_click=start_task, icon="play_arrow")

ui.run(port=8080)

Best Practices for a Smooth Migration

1. Start With a Feature Slice, Not the Whole App

Don't attempt a full rewrite in one go. Identify a single, self-contained feature — a dashboard, a settings page, a reporting tool — and migrate just that slice to NiceGUI. Run it alongside your existing application (NiceGUI can coexist with Flask or Django on different ports or mounted paths). This incremental approach reduces risk and lets your team learn the framework gradually.

2. Preserve Your Business Logic Layer

NiceGUI is a UI framework, not a data framework. Keep your existing service layer, repository classes, validation logic, and database models completely intact. The migration is about replacing the presentation layer, not rewriting business logic. If you have well-tested service functions, they should work unchanged.

3. Use ui.element() for Dynamic Content Areas

When you need to dynamically add or remove UI elements based on user interactions, use container elements like ui.column(), ui.row(), or ui.card() as dynamic content areas. Call .clear() to wipe them and then rebuild the content inside a with block. This pattern replaces template looping constructs from Jinja2 or Django Templates.

# Dynamic content area pattern
dynamic_area = ui.column()

def rebuild_ui(items):
    dynamic_area.clear()
    with dynamic_area:
        for item in items:
            with ui.card():
                ui.label(item["title"])
                ui.button("Delete", on_click=lambda i=item: remove_item(i))

def remove_item(item):
    items.remove(item)
    rebuild_ui(items)

4. Leverage NiceGUI's Binding System

Instead of manually updating labels and inputs, use NiceGUI's binding feature to tie UI elements directly to Python data structures. This eliminates boilerplate update code:

# Binding example — UI auto-updates when data changes
from nicegui import ui

data = {"name": "Alice", "age": 30}

with ui.card():
    ui.input("Name").bind_value(data, "name")
    ui.number("Age").bind_value(data, "age")
    ui.label().bind_text_from(data, "name", lambda n: f"Hello, {n}!")
    
    def birthday():
        data["age"] += 1  # UI updates automatically!
    
    ui.button("Birthday!", on_click=birthday)

5. Test the UI With NiceGUI's Built-in Testing Utilities

NiceGUI ships with a nicegui.test module that lets you write automated tests for your UI using pytest. This is critical when migrating — you can verify that the new UI behaves identically to the old one:

# test_counter.py
from nicegui.test import User

async def test_counter_increment(user: User):
    await user.open("/")
    await user.should_see("Count: 0")
    await user.click("+")
    await user.should_see("Count: 1")
    await user.click("+")
    await user.should_see("Count: 2")

6. Handle Authentication Early

If your legacy app uses session-based auth (Flask-Login, Django sessions), you'll need to adapt it. NiceGUI integrates natively with FastAPI middleware, so you can add authentication via FastAPI's dependency injection or custom middleware. For simple cases, use ui.page() with an authentication guard:

# Simple auth guard example
from nicegui import ui, app

AUTHENTICATED_USERS = set()

@ui.page("/admin")
def admin_page():
    # Check if user is authenticated (simplified example)
    if not app.storage.user.get("authenticated"):
        ui.open("/login")
        return
    ui.label("Admin Dashboard").classes("text-h3")
    # ... admin content ...

@ui.page("/login")
def login_page():
    def try_login():
        if username.value == "admin" and password.value == "secret":
            app.storage.user["authenticated"] = True
            ui.open("/admin")
        else:
            ui.notify("Invalid credentials", type="negative")
    
    username = ui.input("Username")
    password = ui.input("Password", password=True)
    ui.button("Login", on_click=try_login)

7. Package for Desktop Deployment

One of NiceGUI's killer features is the ability to package your web UI as a native desktop application using PyInstaller. This replaces Tkinter/PyQt desktop apps while keeping the modern web UI. Use ui.run(native=True) during development to open a native window, and for production packaging:

# pyinstaller build command example
# pyinstaller --onefile --windowed --add-data ".;." your_app.py
# In your app, use:
ui.run(native=True, window_title="My Desktop App")

Common Pitfalls and How to Avoid Them

Real-World Migration Case Study: Internal Tools Dashboard

Consider a typical scenario: a company has an internal tools dashboard built with Flask, Jinja2, and Chart.js for data visualization. The dashboard displays sales metrics, user activity, and system health — and it requires full page reloads for any data refresh. The migration plan follows these steps:

  1. Audit the existing routes and identify data sources — Keep the database queries and API calls intact.
  2. Create a new NiceGUI app file that imports the existing service layer.
  3. Replace each chart with NiceGUI's built-in ui.chart() or ui.echart() components, which support live data updates.
  4. Replace form submissions with button click handlers that call the same backend functions.
  5. Add auto-refresh using ui.timer() to replace manual browser refreshes.
  6. Deploy the NiceGUI app on a different port initially, allowing side-by-side comparison before cutting over.
# Migrated dashboard snippet — replacing Chart.js with ui.echart
from nicegui import ui
import httpx
import asyncio

async def fetch_sales_data():
    async with httpx.AsyncClient() as client:
        resp = await client.get("https://api.company.com/sales")
        return resp.json()

async def update_chart():
    data = await fetch_sales_data()
    sales_chart.options["series"][0]["data"] = [d["revenue"] for d in data]
    sales_chart.update()

with ui.card().classes("w-full"):
    ui.label("Sales Revenue").classes("text-h5")
    sales_chart = ui.echart({
        "xAxis": {"type": "category", "data": ["Mon", "Tue", "Wed", "Thu", "Fri"]},
        "yAxis": {"type": "value"},
        "series": [{"data": [120, 200, 150, 80, 70], "type": "line"}]
    })

ui.timer(30.0, update_chart)  # Auto-refresh every 30 seconds
ui.run(port=8080)

Conclusion

Migrating from legacy frameworks to NiceGUI is fundamentally about simplifying your stack — collapsing the frontend/backend divide into a single Python codebase while gaining real-time reactivity and modern UI components for free. The key insight is that you don't need to rewrite your business logic or data layer; you're replacing the presentation layer with something more maintainable, more interactive, and deployable anywhere. Start small with a single feature slice, preserve your existing services, leverage bindings and async patterns, and test incrementally. The result is a codebase that's easier to understand, faster to iterate on, and ready for both web and desktop deployment — all without leaving the Python ecosystem.

🚀 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