In This Guide
Key Takeaways
- Streamlit is the fastest way to build an LLM-powered web app in pure Python — no JavaScript, no React, no separate frontend
st.chat_message,st.chat_input, andst.write_streamgive you a production-quality chat UI in about 30 linesst.session_statepersists chat history and other per-user state across script reruns@st.cache_resourceis the right primitive for vector stores, embedding models, and DB connections — load once, share across all users- Best fit: prototypes, internal tools, demos, and AI engineering experiments — not consumer-scale production apps
- Deploy free on Streamlit Community Cloud or Hugging Face Spaces; use Docker plus Railway, Fly.io, or Cloud Run for serious workloads
Why Streamlit for AI Apps?
Streamlit is a Python library that turns ordinary scripts into interactive web apps. You write a .py file, run streamlit run app.py, and a browser tab opens with your UI. No HTML. No CSS. No JavaScript. No frontend framework. For AI engineers — most of whom live in Python and have no desire to wrestle Webpack — this is a dramatic shortcut.
The reason Streamlit dominates AI prototyping in 2026 is simple: every model, vector store, embedding library, agent framework, and evaluation harness in the AI ecosystem ships in Python first. If your tool needs openai, anthropic, langchain, llama-index, chromadb, faiss, sentence-transformers, or transformers, you are already in Python. Streamlit lets you wrap a UI around that work without leaving the language.
Compare the alternatives. Gradio is also Python-native but is optimized for ML model demos — input form, output box, "share to Hugging Face." It is a narrower tool. Next.js gives you the full power of a real web framework, but you need TypeScript, React, and a separate Python backend to talk to your models — that is two stacks instead of one. Chainlit is a newer chat-first Python framework that competes more directly with Streamlit but with less ecosystem and fewer non-chat use cases. For most AI engineers building most AI apps, Streamlit hits the sweet spot of speed and flexibility.
The execution model is also worth understanding upfront because it surprises every newcomer. Streamlit re-runs your entire script from top to bottom every time the user interacts with the page. A button click, a text input change, a slider movement — full script rerun. This sounds inefficient, and it would be, except for two design choices: st.session_state persists data across reruns, and @st.cache_data / @st.cache_resource let you skip expensive work. Once you internalize the rerun model, Streamlit makes sense. Until then, it feels strange.
Setup & First App
Installation is one command. The hello-world app is three lines. The dev loop is built around save-and-rerun, which makes iteration feel instant.
Install Streamlit
# Requires Python 3.9 or newer. Recommended: use a virtualenv. python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install streamlit # Sanity check streamlit hello
streamlit hello opens a demo gallery in your browser. If it works, your install is good. Now write your own app.
Hello World
# app.py import streamlit as st st.title("My First AI App") st.write("Welcome. This is a Streamlit app.") name = st.text_input("What is your name?") if name: st.markdown(f"### Hello, **{name}**")
Run it with streamlit run app.py. The browser opens at http://localhost:8501. Edit the file, save, and Streamlit detects the change and offers a "Rerun" button — or just enable "Always rerun" in the settings menu and saves trigger an automatic refresh. This save-rerun loop is the most addictive thing about Streamlit.
The Core Display Functions
You will use these constantly. st.title, st.header, st.subheader for headings. st.write is a magic function that renders almost anything you pass it — strings, DataFrames, plots, dicts. st.markdown renders Markdown with full GitHub-flavored support. st.code renders syntax-highlighted code blocks. st.json renders pretty-printed JSON. For inputs: st.text_input, st.text_area, st.number_input, st.selectbox, st.slider, st.button, st.file_uploader. That's most of what you need to build an AI app.
Building an LLM Chat UI
Streamlit added first-class chat primitives in version 1.24 — st.chat_message and st.chat_input — and they are the reason Streamlit is now the default for LLM frontends.
The two primitives map directly to the mental model: st.chat_message(role) is a context manager that renders a chat bubble with the right avatar and styling for that role ("user", "assistant", "system"). st.chat_input renders a chat input pinned to the bottom of the viewport that returns the user's text when they submit. Combine them with st.session_state to persist message history across reruns, and you have a real chat UI.
import streamlit as st from anthropic import Anthropic st.title("Chat with Claude") client = Anthropic() # reads ANTHROPIC_API_KEY from env # Initialize message history once per session if "messages" not in st.session_state: st.session_state.messages = [] # Render existing history on every rerun for msg in st.session_state.messages: with st.chat_message(msg["role"]): st.markdown(msg["content"]) # Bottom-pinned input prompt = st.chat_input("Say something...") if prompt: st.session_state.messages.append({"role": "user", "content": prompt}) with st.chat_message("user"): st.markdown(prompt) with st.chat_message("assistant"): response = client.messages.create( model="claude-sonnet-4-6", max_tokens=1024, messages=st.session_state.messages, ) reply = response.content[0].text st.markdown(reply) st.session_state.messages.append({"role": "assistant", "content": reply})
That is a complete, working chat app. Thirty lines of Python, no JavaScript, no API server, no framework boilerplate. Replace the Anthropic call with OpenAI's client.chat.completions.create if you prefer GPT, or with any other LLM provider — the structure is identical.
The pattern to internalize: history lives in st.session_state.messages, every rerun replays it, and new turns are appended after both user submission and model response. Streamlit handles the rendering and the input plumbing for you.
Streaming Responses
The chat UI above waits for the full response before rendering it. For anything longer than a sentence, that feels broken. st.write_stream fixes it.
st.write_stream takes any iterable or generator and renders chunks as they arrive — the same word-by-word effect users expect from ChatGPT or Claude.ai. Both the OpenAI and Anthropic SDKs return streaming iterators when you ask for them, so you wrap their output in a tiny generator and hand it to st.write_stream.
import streamlit as st from anthropic import Anthropic client = Anthropic() if "messages" not in st.session_state: st.session_state.messages = [] for msg in st.session_state.messages: with st.chat_message(msg["role"]): st.markdown(msg["content"]) prompt = st.chat_input() if prompt: st.session_state.messages.append({"role": "user", "content": prompt}) with st.chat_message("user"): st.markdown(prompt) def stream_claude(): with client.messages.stream( model="claude-sonnet-4-6", max_tokens=1024, messages=st.session_state.messages, ) as stream: for text in stream.text_stream: yield text with st.chat_message("assistant"): full_reply = st.write_stream(stream_claude()) st.session_state.messages.append({"role": "assistant", "content": full_reply})
Two things worth highlighting. First, st.write_stream returns the concatenated full string after the stream completes — which is exactly what you need to append to history. Second, the OpenAI streaming pattern is structurally identical: iterate over the stream, yield chunk.choices[0].delta.content or "", and pass the generator into st.write_stream.
Pro Tip: Don't fight the rerun model
Beginners often try to reach for asyncio when they hit streaming. You don't need it. Streamlit's script-rerun model with a synchronous generator is enough for everything most chat apps need. Async only matters when you have many concurrent operations within a single rerun — like fanning out to multiple LLM calls in parallel — and even then concurrent.futures is usually simpler than asyncio in a Streamlit context.
Building a RAG App
RAG — Retrieval-Augmented Generation — is the most common AI engineering pattern in 2026: upload documents, embed them, store in a vector index, retrieve relevant chunks at query time, stuff them into the LLM prompt. Streamlit makes the whole flow fit in a single file.
Here is the full pipeline: st.file_uploader gives you the file, you chunk the text, embed it with a sentence-transformer, store the vectors in a FAISS or Chroma index, and at query time you embed the question, retrieve the top-k chunks, and pass them to the LLM as context.
import streamlit as st from anthropic import Anthropic from sentence_transformers import SentenceTransformer import faiss import numpy as np st.title("Document Q&A with RAG") # Load embedding model ONCE and share across all users/reruns @st.cache_resource def load_embedder(): return SentenceTransformer("all-MiniLM-L6-v2") embedder = load_embedder() client = Anthropic() def chunk_text(text, size=500, overlap=50): words = text.split() chunks = [] for i in range(0, len(words), size - overlap): chunks.append(" ".join(words[i:i + size])) return chunks uploaded = st.file_uploader("Upload a .txt file", type=["txt"]) if uploaded: text = uploaded.read().decode("utf-8") chunks = chunk_text(text) embeddings = embedder.encode(chunks, convert_to_numpy=True) index = faiss.IndexFlatL2(embeddings.shape[1]) index.add(embeddings.astype("float32")) st.session_state.chunks = chunks st.session_state.index = index st.success(f"Indexed {len(chunks)} chunks.") question = st.chat_input("Ask a question about the document") if question and "index" in st.session_state: with st.chat_message("user"): st.markdown(question) q_emb = embedder.encode([question], convert_to_numpy=True).astype("float32") _, idxs = st.session_state.index.search(q_emb, k=4) context = "\n\n---\n\n".join(st.session_state.chunks[i] for i in idxs[0]) prompt = f"""Use the context below to answer the question. Context: {context} Question: {question}""" def stream(): with client.messages.stream( model="claude-sonnet-4-6", max_tokens=1024, messages=[{"role": "user", "content": prompt}], ) as s: for t in s.text_stream: yield t with st.chat_message("assistant"): st.write_stream(stream())
That is a complete RAG app in roughly 60 lines. Swap FAISS for Chroma if you want a more featureful local vector DB (pip install chromadb) — the integration is similar. For multi-format ingestion, add pypdf for PDFs and python-docx for Word documents and route based on the uploaded file type.
The single most important detail in the code above is @st.cache_resource on load_embedder. Without it, Streamlit reloads the SentenceTransformer model on every rerun — a several-second delay every interaction. With it, the model loads once when the app starts and is shared across all users and reruns. Same pattern applies to the FAISS index in production: build it once and cache it, rather than rebuilding on every upload.
State Management & Performance
Three primitives carry almost all of Streamlit's state and performance story: st.session_state, @st.cache_data, and @st.cache_resource. Knowing when to use which is most of what separates fast, smooth Streamlit apps from sluggish ones.
st.session_state — per-user, per-session
st.session_state is a dict-like object scoped to the current user's browser tab. It survives reruns within a session but is wiped when the tab closes. Use it for: chat history, form inputs the user has filled in, the currently selected document, the user's role or preferences. Anything that should persist across interactions but is specific to one user.
@st.cache_data — for serializable return values
@st.cache_data caches the return value of a function based on its arguments. The return value is serialized, so Streamlit can return a fresh copy each call — mutations don't leak between sessions. Use it for: DataFrames loaded from disk or API, parsed JSON, computed metrics, anything you want memoized. Default TTL is unlimited; pass ttl=3600 to expire after an hour.
@st.cache_resource — for global, non-serializable objects
@st.cache_resource caches the actual object instance — same instance returned to every caller. Use it for: ML models (SentenceTransformer, transformers pipelines, loaded ONNX models), database connections, vector store clients, anything that is expensive to initialize and safe to share. The vector index in the RAG example above is a textbook cache_resource case.
Pro Tip: Cache your vector store, not your retrieval results
The right granularity for caching in a RAG app is: @st.cache_resource on the loader that builds the FAISS index from disk; do not cache the retrieve(question) calls. Index loading takes seconds and is shared. Retrieval takes milliseconds and is unique per question — caching it adds overhead with no payoff. Cache the heavy, shared, idempotent work; let the lightweight per-query work run fresh.
One more thing about performance: if your script does anything heavy at import time — loading a model, opening a DB — wrap it in a @st.cache_resource function. Streamlit re-runs the script top-to-bottom on every interaction, and uncached imports re-execute every time. The cache decorators are how you opt out of that for the work that should only happen once.
Streamlit vs Gradio vs Chainlit vs Next.js
Each tool has a real best-fit zone. The question is what you are building, who it is for, and how much UI control you actually need.
| Feature | Streamlit | Gradio | Chainlit | Next.js + FastAPI |
|---|---|---|---|---|
| Language | Python | Python | Python | TypeScript + Python |
| Best for | General AI apps, dashboards, internal tools | ML model demos | Pure chat apps | Production consumer apps |
| LLM chat UI | First-class (chat_message) | First-class (ChatInterface) | Built around chat | You build it |
| Streaming | st.write_stream | Yes | Yes | Full SSE/WebSocket control |
| Multi-page apps | Native (pages/ dir) | Limited | Chat-focused | Full router |
| UI customization | Theme + components | Theme | Theme | Total control |
| Auth & rate limiting | Bring your own | Bring your own | Built-in (basic) | Full ecosystem |
| HF Spaces deploy | Yes | Optimized for it | Yes | Custom Docker |
| Time to first prototype | Minutes | Minutes | Minutes | Hours to days |
The decision tree most AI engineers actually use: pick Gradio if your app is fundamentally "input → ML model → output" and you want a Hugging Face Space in five minutes. Pick Chainlit if your app is exclusively a chat interface and you want auth and conversation tracking out of the box. Pick Streamlit for everything else where Python plus a real UI is the right tradeoff. Pick Next.js + FastAPI when you are building a real consumer product that needs custom UI, real auth, and real scale — and accept that you are now running two stacks.
Deployment
Five reasonable deployment paths for Streamlit apps in 2026, each with a different best-fit case.
Streamlit Community Cloud — free, push-to-deploy
Streamlit Community Cloud is the official free hosting tier. Connect your GitHub repo, point it at your app.py, and you get a public URL. Resource limits are modest (1 GB RAM per app on the free tier) but more than enough for demos and side projects. Best for: portfolios, demos, hackathon submissions, public-good projects. Not appropriate for anything with sensitive data or non-trivial traffic.
Hugging Face Spaces — best for ML demos
Spaces supports Streamlit as a first-class runtime alongside Gradio. Free CPU tier, paid GPU tiers if you need to host a local model. The integration with the Hugging Face Hub for datasets and models is the main reason to choose Spaces over Streamlit Cloud — if your app loads transformers models or datasets, Spaces is faster.
Docker + Railway / Fly.io / Render
For internal tools, paying customers, or anything you want real control over: containerize your Streamlit app with a 10-line Dockerfile and push to Railway, Fly.io, or Render. You get custom domains, environment variables for secrets, scaling, logs, and metrics. Cost is typically $5–$30/month for a small app. This is the right path for most production deployments.
# Dockerfile FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8501 CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
Cloud Run / ECS / Azure Container Apps
If you are already on a cloud provider with security and compliance requirements, run your Docker image on the provider's container service. Cloud Run is the simplest of the three — give it your image, it gives you a URL with autoscaling and HTTPS. Note that Streamlit holds a long-lived WebSocket per user, so configure max-concurrency and idle-timeout accordingly.
Cloudflare Pages — not a fit
To set expectations: Cloudflare Pages hosts static sites and serverless functions. It does not run long-lived Python processes. You cannot deploy Streamlit to Cloudflare Pages. If you want Cloudflare in front of your Streamlit app, run the app on Railway/Fly/Cloud Run and put Cloudflare in front as a CDN/proxy.
Production Best Practices
Streamlit is excellent for prototypes and internal tools. The decision to push it to a real, untrusted, paying-customer audience requires honestly addressing a handful of gaps.
Secrets management
Never hard-code API keys. In local dev, use a .env file with python-dotenv. On Streamlit Community Cloud, use the Secrets section of the app settings — values are exposed via st.secrets. On Docker hosts, use environment variables. Same discipline as any other Python app.
Authentication
Streamlit ships with st.experimental_user and OIDC-based auth in recent versions, which is enough for many internal-tool cases. For anything more serious — multi-tenant SaaS, role-based access, customer login — front your app with an auth proxy (Cloudflare Access, Tailscale, an OAuth gateway) or move to a Next.js/FastAPI architecture. Don't try to build full auth inside a Streamlit app; the tool is not designed for that.
Rate limiting
Streamlit has no built-in rate limiter, and LLM API costs make this a real concern the moment your app is public. Two reasonable patterns: a per-IP token bucket using streamlit-authenticator + Redis for shared state, or — better — a reverse proxy (Cloudflare, NGINX) doing the rate limiting before the request ever hits Streamlit. The proxy approach is dramatically simpler and survives restarts.
Error handling
LLM and vector-store calls fail. Wrap them in try/except and surface friendly messages with st.error. For background work where exceptions would otherwise vanish, log them — Streamlit's default exception display is good for development but reveals stack traces you don't want in production. Set client.showErrorDetails = false in config.toml for production deploys.
The honest production limit
Streamlit's script-rerun model and per-user WebSocket are fine for tens to a few hundred concurrent users on a single instance. Beyond that, the architecture starts to creak — sticky sessions across multiple replicas, shared state in Redis, careful resource budgeting. At that scale the right move is usually not "scale Streamlit harder" but "rewrite the frontend in something built for scale and keep Streamlit for the internal admin." Use the right tool for the right phase.
Sources: Streamlit Documentation, Anthropic API Documentation, OpenAI Platform Docs. API surfaces verified against current Streamlit releases as of April 2026; Streamlit's release notes for new chat and streaming primitives at docs.streamlit.io/develop/api-reference.
Explore More Guides
Streamlit's real role in 2026 is prototyping and internal tools — not consumer products.
Streamlit gets sold as "build any web app in Python." The honest version is narrower and more useful: Streamlit is the fastest tool in existence for getting an AI prototype in front of stakeholders, and it is the right default for internal tools where the audience is small, trusted, and patient. We have built dozens of Streamlit apps for client demos, RAG prototypes, evaluation harnesses, and admin dashboards — and in every one of those cases it was the right tool. The build-to-demo time is unmatched.
Where Streamlit consistently disappoints is the consumer-product use case. The script-rerun model means every interaction triggers a full top-to-bottom execution; the WebSocket-per-user model makes scaling to thousands of concurrent users non-trivial; the customization ceiling is real once you want anything beyond Streamlit's component vocabulary. Trying to push Streamlit into a polished consumer product almost always ends with rebuilding the frontend in Next.js six months later, with the Streamlit version surviving as the internal admin panel. That is fine — that is the right outcome — but plan for it on day one rather than fighting it on day 180.
The right mental model is to treat Streamlit as the AI engineer's wireframe-to-working-app pipeline. Build the prototype in Streamlit, validate the LLM behavior, validate the retrieval quality, validate the user flow with stakeholders, then make a deliberate decision about whether the production version stays in Streamlit (often yes, for internal tools and B2B admin) or moves to a separate frontend (almost always for B2C). The mistake is treating "Streamlit prototype" and "production app" as the same artifact. They aren't. Knowing the difference is half of using Streamlit well.