Python has always been convenient for writing scripts, automating tasks, analyzing data, or building small services. The problem appears when that script starts needing an interface: a form, a table, a button to launch a task, or a chart to show results. For years, Streamlit has been one of the fastest answers to that scenario, especially in data and machine learning. But NiceGUI is gaining interest among developers who want a web UI closer to a traditional application without leaving Python.

NiceGUI does not try to turn Python into React or replace a professional frontend when the product requires one. Its strength lies elsewhere: it allows developers to create web interfaces with components declared in Python, runs the logic on the server, and updates the browser through events. For internal tools, admin panels, prototypes, lab apps, robotics, IoT, or data utilities, that combination can save a lot of time.

Why NiceGUI attracts Python developers

NiceGUI defines itself as a Python framework for creating interfaces displayed in the browser. The official documentation highlights that it can build buttons, dialogs, Markdown, 3D scenes, charts, and other components from Python, and positions it for web micro-apps, dashboards, robotics, home automation, and machine learning tools.

Its architecture explains part of the appeal. NiceGUI uses FastAPI for the HTTP layer, WebSockets for real-time updates, and Vue plus Quasar for visual components. Developers do not need to write that frontend layer to get started. They define components in Python, receive events in callbacks, and can call pandas, NumPy, AI models, internal APIs, or any other ecosystem library directly.

A “hello world” is almost as direct as expected:

from nicegui import ui

ui.label('Hello, NiceGUI')
ui.button('Click me', on_click=lambda: ui.notify('Button clicked'))

ui.run(title='NiceGUI Demo', port=8080)Code language: JavaScript (javascript)

When you run:

python app.pyCode language: CSS (css)

the application is available at http://localhost:8080. There is no HTML template, no JavaScript bundle, and no initial separation between backend and frontend.

The difference with Streamlit lies in the mental model. Streamlit works very well when you want to turn a data script into an interactive app. Its model is based on rerunning the script when the user interacts with widgets, although recent versions include mechanisms such as fragments to better control that execution. NiceGUI, by contrast, feels more like an event-driven app: you define components, attach callbacks, and update specific parts of the interface.

AspectStreamlitNiceGUI
Main modelInteractive script that rerunsComponents, events, and callbacks
Strong use casesData, demos, ML, quick analysisInternal tools, panels, control apps, lightweight web apps
Layout controlSimple and enough for dashboardsCloser to a component-based UI
Server logicPythonPython
Frontend visible to the developerHighly abstractedHighly abstracted, but with more composition
Learning curveVery lowLow, slightly more structured

Layouts, events, and state with real code

NiceGUI uses context managers to define the visual hierarchy. Python indentation marks which component lives inside another:

from nicegui import ui

with ui.header(elevated=True).classes('bg-blue-800 text-white'):
    ui.label('Internal Panel').classes('text-xl font-bold')

with ui.row().classes('w-full gap-4 p-4'):
    with ui.card().classes('w-1/2'):
        ui.label('System status').classes('text-lg font-semibold')
        ui.badge('OK', color='green')

    with ui.card().classes('w-1/2'):
        ui.label('Actions').classes('text-lg font-semibold')
        ui.button('Restart task', on_click=lambda: ui.notify('Task restarted'))

ui.run()Code language: JavaScript (javascript)

The .classes() method applies Tailwind-style utilities to adjust width, margins, colors, or layout. You do not need to write CSS for a simple internal tool, although understanding basic classes helps prevent the interface from looking unfinished.

State can be managed in several ways. For a local demo, a global dictionary may be enough, but in multi-user applications it is better to use per-user state or define it inside each page to prevent two sessions from accidentally sharing data:

from nicegui import ui, app

@ui.page('/')
def index():
    counter = app.storage.user.get('counter', 0)
    label = ui.label(f'Clicks: {counter}')

    def increment():
        app.storage.user['counter'] = app.storage.user.get('counter', 0) + 1
        label.set_text(f"Clicks: {app.storage.user['counter']}")

    ui.button('Add', on_click=increment)

ui.run(storage_secret='change-this-secret')Code language: JavaScript (javascript)

That detail matters. If a global variable is used to store application data, all users may end up seeing or modifying the same state. In an internal tool with several colleagues connected, that mistake appears sooner than expected.

Complete example: CSV explorer with NiceGUI

A practical case for developers is building a small CSV explorer: upload a file, read it with pandas, display a table, and plot a numeric column. The following example is functional and can serve as a base for more complex internal panels.

First, install the dependencies:

pip install nicegui pandas

Then create app.py:

from nicegui import ui, events
import pandas as pd
import io

@ui.page('/')
def main():
    state = {'df': None}

    with ui.header(elevated=True).classes('bg-slate-900 text-white'):
        ui.label('CSV Data Explorer').classes('text-xl font-bold')

    with ui.column().classes('w-full p-6 gap-4'):

        with ui.card().classes('w-full'):
            ui.label('Upload CSV').classes('text-lg font-semibold')
            upload = ui.upload(auto_upload=True).classes('w-full')

        with ui.card().classes('w-full'):
            ui.label('Preview').classes('text-lg font-semibold')
            table = ui.column().classes('w-full')

        with ui.card().classes('w-full'):
            ui.label('Visualization').classes('text-lg font-semibold')
            with ui.row().classes('items-center gap-4'):
                selector = ui.select([], label='Numeric column').classes('w-64')
                button = ui.button('Plot', icon='bar_chart')
            chart = ui.column().classes('w-full')

    def refresh_table() -> None:
        df = state['df']
        if df is None:
            return

        table.clear()
        chart.clear()

        selector.options = list(df.columns)
        selector.value = None
        selector.update()

        columns = [
            {'name': col, 'label': col, 'field': col}
            for col in df.columns
        ]

        rows = df.head(20).fillna('').to_dict('records')

        with table:
            ui.table(
                columns=columns,
                rows=rows,
                row_key=df.columns[0],
                pagination=10,
            ).classes('w-full')

    def load_csv(e: events.UploadEventArguments) -> None:
        try:
            content = e.content.read()
            df = pd.read_csv(io.BytesIO(content))
            state['df'] = df
            refresh_table()
            ui.notify(
                f'CSV loaded: {len(df)} rows and {len(df.columns)} columns',
                type='positive',
            )
        except Exception as exc:
            ui.notify(f'Could not read CSV: {exc}', type='negative')

    def plot_column() -> None:
        df = state['df']
        col = selector.value

        if df is None:
            ui.notify('Upload a CSV first', type='warning')
            return

        if not col:
            ui.notify('Select a column', type='warning')
            return

        if not pd.api.types.is_numeric_dtype(df[col]):
            ui.notify(f'The column "{col}" is not numeric', type='warning')
            return

        series = df[col].dropna().head(50)

        chart.clear()
        with chart:
            ui.echart({
                'title': {'text': f'First 50 values of {col}'},
                'tooltip': {'trigger': 'axis'},
                'xAxis': {
                    'type': 'category',
                    'data': [str(i) for i in range(len(series))],
                },
                'yAxis': {'type': 'value'},
                'series': [{
                    'type': 'bar',
                    'data': series.tolist(),
                }],
            }).classes('w-full h-96')

    upload.on_upload(load_csv)
    button.on_click(plot_column)

ui.run(title='CSV Data Explorer', port=8080, reload=False)Code language: JavaScript (javascript)

The app has four basic behaviors: it accepts a valid CSV, displays its first rows, allows the user to choose a column, and plots the first 50 values if the column is numeric. If the user uploads an invalid file or chooses text instead of numbers, they receive a notification.

This example also shows a recommended practice: the DataFrame state lives inside the page function, not in a global variable shared by all users. For a real app, you would need to add file size limits, column name validation, authentication, and perhaps persistence, but the base pattern is already there.

Async: avoid blocking the interface

NiceGUI runs on an asynchronous environment. If a callback executes a heavy task, it can block updates for other users. For input/output operations, API calls, or CPU-heavy processes that are not async, it is better to offload the work:

import asyncio
from nicegui import ui

def heavy_task(number: int) -> int:
    # Simulates expensive computation
    total = 0
    for i in range(5_000_000):
        total += i % number
    return total

async def run():
    spinner = ui.spinner(size='lg')
    try:
        result = await asyncio.to_thread(heavy_task, 7)
        ui.notify(f'Result: {result}', type='positive')
    finally:
        spinner.delete()

ui.button('Run heavy task', on_click=run)
ui.run()Code language: PHP (php)

For small scripts this will not always be necessary, but in a tool used by several people it is better to separate the interface from slow tasks. In more serious projects, you can use an external queue, Celery, RQ, separate processes, or custom workers.

Deployment and real-world limits

NiceGUI deploys as a Python web application. Locally, ui.run() is enough, but in production it must be treated like any other service: reverse proxy, HTTPS, authentication, logs, upload permissions, memory limits, and error handling. You also need to decide whether the tool will be an internal utility behind a VPN, an app behind corporate authentication, or a service exposed to the internet.

NiceGUI makes sense when the team wants to move quickly without creating two separate projects, backend and frontend, from day one. But it is not the best choice if the application needs a highly customized visual experience, a dedicated frontend team, public SEO, complex product routes, or an advanced SPA architecture. In those cases, FastAPI plus React, Vue, or Svelte may be a more sustainable decision.

For Python developers, the takeaway is practical: Streamlit remains excellent for analysis, demos, and quick data apps. NiceGUI is worth considering when you need a more structured interface with events, persistent components, and behavior closer to an internal web application. It does not remove the need for good design, but it greatly reduces the distance between “I have a useful script” and “my team can use it from the browser”.

Frequently asked questions

Can NiceGUI be used to build real applications or only demos?
It can be used for real internal applications, admin panels, dashboards, and operational tools. In production, you need authentication, reverse proxy, HTTPS, upload limits, and resource control.

Is NiceGUI better than Streamlit?
It depends on the use case. Streamlit is usually more direct for data analysis and quick demos. NiceGUI fits better when you want a component-based UI, callbacks, and events closer to a web application.

Can I use pandas, NumPy, or AI models inside NiceGUI?
Yes. Callbacks run in Python, so they can call ecosystem libraries such as pandas, NumPy, scikit-learn, PyTorch, the OpenAI SDK, or internal APIs.

Do I need to know JavaScript to use NiceGUI?
Not to get started. The interface is written in Python. Knowing HTML, CSS, or web concepts helps when you want deeper customization or more complex deployments.

Scroll to Top