NiceGUI Tutorial: Build a Python GUI App in 5 Minutes
NiceGUI tutorial for beginners: install NiceGUI, build your first Python GUI with labels, rows, inputs and buttons, then run it in production. Updated for 3.x.
34 min read

NiceGUI is a Python framework for building web GUIs. Vue and Quasar render the frontend on top of FastAPI, Tailwind CSS handles styling, and everything you write stays in Python. This NiceGUI tutorial gets a working Python GUI running in about five minutes, then covers what changed in 3.x and how to run the app in production.
As of October 2026 NiceGUI is at version 3.18.0 (released 2026-10-07), with 16.3k GitHub stars, roughly 890k PyPI downloads per month, and an MIT license maintained by Zauberzeug. It requires Python 3.10 or newer.
Over time I checked a few Python frameworks like Streamlit and Taipy before deciding to stick with NiceGUI. Streamlit is faster for a throwaway dashboard; NiceGUI wins when I want control over layout, styling, and speed. If you are still choosing, see the Streamlit vs. NiceGUI comparison, Streamlit vs Taipy, or the full list of Python web UI frameworks.
Every snippet below runs on NiceGUI 3.18.0. That is the point of a NiceGUI tutorial: install it, copy the code, verify it serves on port 8080.
NiceGUI Features
These are the parts that matter when you build a Python GUI with NiceGUI, and the ones I keep using in real apps:
- Performance holds up because the UI talks to your Python code over a WebSocket, so interactions feel instant. Visitors will not guess that Python is behind it.
- Customization goes far: Tailwind CSS classes (Tailwind 4 since 3.0, optional UnoCSS since 3.7), Quasar props, and raw CSS cover almost any design. You can also drop in JavaScript when you need it.
- Day to day you write Python and nothing else. The documentation and the example gallery cover most patterns.
- Components cover the common cases:
row,column,markdown,image,slider,card,table,aggrid,editor,upload, and plenty more, all bindable to plain Python variables. ui.sub_pageswith therootparameter (since 3.0) builds a single-page app with client-side navigation in one file, without route boilerplate.- The event system moves data between UI elements and your backend code without hand-wired callbacks everywhere.
.props(),.classes(), and.style()are observable: change them and the browser updates, without a follow-up.update()call.- A built-in testing framework drives your UI from Python tests instead of clicking through it by hand.
That is not the full list. Check the NiceGUI documentation for the component reference.
Getting Started With NiceGUI
In the next sections you install NiceGUI, run a first page, and then walk through the components and customizations used in most apps. Bitdoze Tools is built with NiceGUI if you want to see a deployed example.
Video vs. current version
The video was recorded in March 2024 against the 1.x-era UI. Every snippet below runs on NiceGUI 3.18.0 (October 2026), so you can follow along with the current package.
Install and Run NiceGUI
What you need before the pip command:
- Python 3.10 or newer. NiceGUI 3.0 dropped Python 3.8 and the package now requires 3.10+. Check with
python3 --version. - pip (ships with Python) or set up the project with uv if you want faster installs and cleaner virtual environments.
- Port 8080 free on localhost (the default; changeable).
- Any Linux, macOS, or Windows machine. No Node.js, no npm.
pip
pip install niceguiuv
uv add niceguiCreate a file called main.py:
from nicegui import ui
ui.label('Hello NiceGUI!')
ui.run()The anatomy is always the same:
from nicegui import uiimports the module, like most other UI frameworks.- The middle is your UI.
ui.label('Hello NiceGUI!')puts text on the page; everything else below is variations on this line. ui.run()starts the server.
Run it:
python3 main.pyVerify: NiceGUI prints the URL and serves on http://localhost:8080. In a terminal you can confirm with:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080You want 200. A blank response or Connection refused means the process died or the port differs; read the terminal output.
ui.run() ships with dev defaults
ui.run() runs with reload=True (rebuilds the UI when you save the file) and show=True (opens your browser). That is what you want on a laptop. On a server both are wrong: flip them off with ui.run(reload=False, show=False).
One 3.x nuance worth knowing now: code in the global scope like the example above is a “NiceGUI script”. It works, but the UI is re-created on every page visit and cannot be shared across clients. When you outgrow that (shared headers, multiple URLs), you move to @ui.page functions or ui.run(root=...). That is what the add multiple pages to NiceGUI guide covers.
Add Markdown
Formatted text goes through ui.markdown:
ui.markdown('# This is H1 Header')
ui.markdown('## This is H2 Header')
ui.markdown('### This is H3 Header')Markdown headers map straight to HTML headings: one # is an H1, two are an H2, three are an H3.
Add Rows
# row element
with ui.row():
ui.label(' First row item')
ui.label(' Second row item')
ui.label(' Third row item')This creates a row layout: the labels sit side by side, horizontally. Put whatever elements you need inside the with ui.row(): block.
Add Columns
# Column element
with ui.column():
ui.label(' First column item')
ui.label(' Second column item')
ui.label(' Third column item')Same idea, stacked vertically. ui.column() is the default container in most of my pages; ui.row() when I need things next to each other.
Styling Components with Tailwind Classes
with ui.column():
ui.label(' First column item').classes('font-bold')
ui.label(' Second column item').classes('text-2xl')
ui.label(' Third column item').classes('text-red-600 text-2xl').classes() takes any Tailwind utility classes. NiceGUI 3.x ships Tailwind CSS 4, and the common utilities (font-bold, text-2xl, text-red-600, shadow-lg, rounded-lg) behave the same as before. In 3.x these classes are also observable: changing them updates the browser immediately, without an .update() call.
Inputs and Buttons (Your First Interaction)
with ui.row():
ui.input(label='Type Something').props('square outlined dense').classes('shadow-lg')
ui.button('Click Me')This adds an input with a label and a button in a row. .props() sets Quasar props (square outlined dense is a Quasar prop trio), while .classes() adds Tailwind classes. Mixing the two is the normal styling workflow.
Static elements are fine, but the point of a Python GUI is calling Python. Here is the smallest useful NiceGUI button on_click example:
from nicegui import ui
with ui.card():
name = ui.input(label='Your name')
ui.button('Greet', on_click=lambda: ui.notify(f'Hello, {name.value}!'))
ui.run()Here is what happens: on_click registers a Python callback for the button. name.value reads whatever is in the input at click time. ui.notify(...) pops a small toast message on the page. Run it, type your name, click Greet. That is a working interactive app in seven lines.
Add Images
ui.image('https://picsum.photos/id/24/600/400').classes('h-auto max-w-lg rounded-lg flex justify-center')ui.image takes a URL (or a local file path in your project) and the usual Tailwind classes apply: automatic height, capped width, rounded corners, centered on the page. Prefer a local asset or a stable URL in real apps; hashed build artifacts move when you rebuild the site.
Add Header with Drawer Toggle
with ui.header(elevated=True).style('background-color: #3874c8').classes('items-center justify-between'):
ui.label('HEADER')
ui.button(on_click=lambda: right_drawer.toggle(), icon='menu').props('flat color=white')This creates a colored header bar with a title label and a hamburger button that toggles a right-side drawer. Note that the button references right_drawer before it is defined in the file; that works because the lambda runs later, at click time.
Add Right Sidebar/Drawer
with ui.right_drawer(fixed=False).style('background-color: #ebf1fa').props('bordered') as right_drawer:
ui.label('RIGHT DRAWER')The as right_drawer part is what the header button calls .toggle() on. Keep the header block before this one in the file so the variable reads naturally.
Add Footer
with ui.footer().style('background-color: #3874c8'):
ui.label('FOOTER')A footer pinned to the bottom of the page. Split it into ui.row() sections or columns if you need link groups.
Add Custom Code to the Header
ui.add_head_html('''
<script></script>
''')ui.add_head_html injects raw HTML into the page <head>. That is how you add JavaScript, custom CSS, or an analytics snippet.
The Complete Example File
Put everything together and the file looks like this:
from nicegui import ui
# demonstrate h1 and others
ui.markdown('# This is H1 Header')
ui.markdown('## This is H2 Header')
ui.markdown('### This is H3 Header')
ui.separator()
# row element
with ui.row():
ui.label(' First row item')
ui.label(' Second row item')
ui.label(' Third row item')
ui.separator()
# Column element
with ui.column():
ui.label(' First column item')
ui.label(' Second column item')
ui.label(' Third column item')
# style the text
ui.separator()
with ui.column():
ui.label(' First column item').classes('font-bold')
ui.label(' Second column item').classes('text-2xl')
ui.label(' Third column item').classes('text-red-600 text-2xl')
# other elements
ui.separator()
with ui.row():
ui.input(label='Type Something').props('square outlined dense').classes('shadow-lg')
ui.button('Click Me')
# image add
ui.separator()
ui.image('https://picsum.photos/id/24/600/400').classes('h-auto max-w-lg rounded-lg flex justify-center')
# header with right drawer
with ui.header(elevated=True).style('background-color: #3874c8').classes('items-center justify-between'):
ui.label('HEADER')
ui.button(on_click=lambda: right_drawer.toggle(), icon='menu').props('flat color=white')
with ui.right_drawer(fixed=False).style('background-color: #ebf1fa').props('bordered') as right_drawer:
ui.label('RIGHT DRAWER')
# footer
with ui.footer().style('background-color: #3874c8'):
ui.label('FOOTER')
ui.run() # dev: http://localhost:8080, auto-reload, opens your browser
# server/prod: ui.run(host='0.0.0.0', port=8080, reload=False, show=False, storage_secret='change-me')Verify: python3 main.py, then open http://localhost:8080. You should see the markdown headers, the row and column items, the red bold text, the input and button, the image, the blue header with a menu button, and the footer. Click the menu button: the right drawer slides in.
This scratches the surface of what NiceGUI can do, but it is enough to understand the building blocks. Next, wire the buttons and inputs to your Python functions (see the on_click example above), then take a look at the production setup further down.
NiceGUI 3.x: What Changed Since 2.x
If you last touched NiceGUI when this article was first published, you are on 1.x-era code. The version history looks like this:
- 1.4.x in March 2024 (when this article went live)
- 2.0.0 on 2024-08-31, when the project adopted semantic versioning
- 3.0.0 on 2025-10-03, with the breaking changes listed below
- 3.18.0 on 2026-10-07, the current release
The beginner-relevant parts of the v3.0.0 release:
- The shared auto-index page is gone. You now pick one of three models: a plain script (global scope, like this tutorial),
ui.run(root=my_function)for a single-page app, or@ui.pagefunctions everywhere. Do not mix global-scope UI with@ui.pagein the same file. .props(),.classes(), and.style()are observable. Delete the follow-up.update()calls.ui.table,ui.aggrid,ui.echart,ui.json_editor,ui.tree: mutatetable.rows/aggrid.optionsdirectly instead of rebuilding the original Python lists.- Tailwind CSS 4 replaced Tailwind 3.4. The
element.tailwindPython API was removed. Standard utility classes still work. - Python 3.8 support was dropped. Today the package requires Python 3.10+.
ui.htmlandui.chat_message(text_html=True)now take an explicitsanitizeargument.- Upload event handlers get a single
e.fileobject (.name,.content_type,.read(),.save()) instead of separatecontent/name/typeargs. ui.openbecameui.navigate.to.
Worth exploring once the basics work: ui.sub_pages for SPA navigation, make_sortable() for drag and drop (3.11), ui.popup (3.15), ui.parallax (3.9), app.colors theming (3.6), and ui.on_exception (3.6).
Upgrading a production app from 1.x or 2.x?
Read the v3.0.0 release notes before touching production. The script-mode and sanitize changes bite hardest.
Do the old snippets in this tutorial still work?
Yes. The combined example file above runs on NiceGUI 3.18.0 (October 2026). The 3.x breaking changes affect app architecture (page model), data binding for tables, and a handful of security-related arguments, not the element API used here.
Is TypeScript required to use NiceGUI?
No. NiceGUI is a Python framework. That old “improved TypeScript support” claim floating around was wrong; you write Python, and the Vue/Quasar frontend is generated for you. You only touch JavaScript when you explicitly inject it.
Do I need to rewrite my app to use @ui.page?
Not if your current app is a single global-scope script and that suits you. Move to @ui.page or ui.run(root=...) when you need multiple URLs, a shared layout across visitors, or per-user state that survives navigation. The multi-page guide linked in the Getting Started section covers all three models.
Run NiceGUI in Production (Docker + Reverse Proxy)
Once the app works locally, you need somewhere to run it. For most internal tools and small public apps a 4-5 EUR/month VPS from Hetzner Cloud is enough CPU and RAM. This tutorial’s example app is one small Python process, so even the cheapest tier has headroom.
NiceGUI runs as a single uvicorn worker with full async I/O. Do not scale it by adding workers. For CPU-bound work use run.cpu_bound(...) to push jobs into a process pool instead.
Production ui.run() flags
prod main.py
import os
from nicegui import ui
ui.label('Hello from production')
ui.run(
host='0.0.0.0',
port=8080,
reload=False,
show=False,
storage_secret=os.environ['STORAGE_SECRET'],
)storage_secret is required if you use app.storage.user or app.storage.browser; without it those sessions do not persist. On a server also set reload=False and show=False.
docker-compose.yml
The official zauberzeug/nicegui image (about 100k pulls on Docker Hub) expects your code in /app and runs it as a non-root user set by PUID/PGID:
services:
nicegui:
image: zauberzeug/nicegui:latest
container_name: nicegui_app
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./app:/app
- nicegui_storage:/app/.nicegui
environment:
- PUID=1000
- PGID=1000
- STORAGE_SECRET=change-me-to-a-long-random-string
volumes:
nicegui_storage:Put the production main.py from the first tab in ./app/main.py. The nicegui_storage volume keeps .nicegui session data across restarts.
Nginx
NiceGUI talks to the browser over a WebSocket. A reverse proxy without upgrade headers breaks it (symptom: “connection lost” after the page loads). Minimum nginx config:
server {
listen 443 ssl;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}NiceGUI 3.14.0 also hardened X-Forwarded-Prefix reflection, so keep the image current if you serve under a path prefix.
Verify after docker compose up -d:
docker compose logs -f nicegui # uvicorn startup line, no tracebacks
curl -I http://localhost:8080 # HTTP/1.1 200 OKThrough the proxy, open browser devtools and confirm the WebSocket request returns 101 Switching Protocols. If it returns 404 or hangs, your proxy is not forwarding the upgrade headers.
If you want the general patterns for containerizing Python apps, see how to run any Python app in Docker with Docker Compose. The same compose layout works for FastHTML, Streamlit, or a plain FastAPI service.
Rollback is one command: docker compose down. Add -v only when you intend to delete the .nicegui storage volume as well.
Do not expose an outdated NiceGUI to the internet
NiceGUI patched a steady stream of CVEs through 2025 and 2026: XSS via ui.markdown (GHSA-v82v-c5x8-w282, fixed in 3.7.0), path traversal in FileUpload.name (3.7.0), XSS via run_method() (3.8.0), memory exhaustion via media streaming (3.9.0) and leaked clients (3.16.0), and unguessable download routes (3.17.0). Run pip install -U nicegui or pull zauberzeug/nicegui:latest before deploying, and keep updating after that.
reload=Falseandshow=Falseinui.run()storage_secretset from an environment variable, not hardcoded- Container runs as non-root (
PUID/PGIDor auser:directive) .niceguistorage dir persisted in a volume- WebSocket upgrade headers configured on the reverse proxy
- Image pinned or updated on a schedule, not left at a 2025 tag
One demo-only option: ui.run(on_air=True) gives you a temporary public tunnel URL. It is labeled a tech preview; fine for showing something to a colleague, not for production.
For a parallel deployment walkthrough, including TLS via tunnels, see how to deploy Streamlit on a VPS behind Cloudflare Tunnels. If you would rather keep everything at home, you can self-host NiceGUI on a home server mini PC at zero hosting cost plus electricity.
First-Run Errors and Fixes
The five failures you are most likely to hit, with the actual error first.
[Errno 98] error while attempting to bind on address ('0.0.0.0', 8080): address already in use
Something else holds port 8080: another NiceGUI dev run, a leftover Docker container (docker ps | grep 8080), or any other service. Either stop that process or move yours: ui.run(port=8000).
SyntaxError or install failures on a clean machine
Almost always an old interpreter: NiceGUI 3.x requires Python 3.10 or newer (python3 --version). On Debian/Ubuntu with an old system Python, install a newer interpreter or use uv to manage the project Python instead of fighting the distro package.
Browser does not open, or the app hot-reloads on a server
Those are the show=True and reload=True dev defaults in ui.run(). On a server use ui.run(show=False, reload=False).
UI loads, then shows 'connection lost' behind Nginx or Cloudflare
The reverse proxy is not forwarding WebSocket upgrade headers. Add proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade, and proxy_set_header Connection "Upgrade" as in the Nginx tab with the reverse proxy WebSocket headers above. Cloudflare tunnels pass WebSockets through by default; plain nginx does not.
app.storage.user crashes, or sessions vanish on restart
You are missing storage_secret in ui.run(), or the container’s .nicegui directory is not in a persistent volume. Set storage_secret=os.environ['STORAGE_SECRET'] and mount the volume as in the compose file above.
Why Some Will Probably Not Like NiceGUI
NiceGUI gives you a fast, fully customizable Python UI. The price of that control is CSS. At some point you will write Tailwind classes or tweak Quasar props to get the look you want, and if you have never touched CSS that feels like a wall in the beginning. Streamlit is the other path: throw code at it and the framework makes decisions for you. For a quick internal dashboard that trade is often right.
NiceGUI 3.x softened the curve slightly (Tailwind 4 is simpler than 3, and ui.sub_pages removes a lot of routing glue), but the trade-off is unchanged: control versus magic. If you want a performant Python GUI you can shape, NiceGUI is the better fit.
Conclusions
This NiceGUI tutorial took you from pip install nicegui through layout, Tailwind styling, a first interactive button, and a production deployment with Docker and a reverse proxy. The code was verified on NiceGUI 3.18.0, current as of October 2026.
Next steps: the NiceGUI documentation for the full component list, and the multi-page guide once you need routing. If you use AI coding agents, one practical tip: NiceGUI ships a compact API reference at nicegui/llms.md inside the pip package (also at nicegui.io/llms.txt). Paste that into your agent instead of scraping the docs site.


