diff --git a/content/develop/concepts/custom-components/components-v2/package-based.md b/content/develop/concepts/custom-components/components-v2/package-based.md
index d7597b701..d369f58af 100644
--- a/content/develop/concepts/custom-components/components-v2/package-based.md
+++ b/content/develop/concepts/custom-components/components-v2/package-based.md
@@ -22,6 +22,15 @@ Choose package-based components when you need one of the following features:
The fastest way to create a package-based component is with the official [component template](https://github.com/streamlit/component-template). It generates a complete project with all the configuration, build tooling, and boilerplate you need.
+
+
+For step-by-step walkthroughs of using the template, see the tutorials:
+
+- [Create a component with Pure TypeScript](/develop/tutorials/custom-components/template-typescript)
+- [Create a component with React + TypeScript](/develop/tutorials/custom-components/template-react)
+
+
+
### Prerequisites
- Python >= 3.10
diff --git a/content/develop/tutorials/_index.md b/content/develop/tutorials/_index.md
index d937a69ce..d9ba06bee 100644
--- a/content/develop/tutorials/_index.md
+++ b/content/develop/tutorials/_index.md
@@ -59,6 +59,14 @@ Build simple apps and walk through examples to learn about Streamlit's core feat
+
+
+
Build custom components
+
+Build package-based custom components with the official component template.
+
+
+
Create multipage apps
diff --git a/content/develop/tutorials/custom-components/_index.md b/content/develop/tutorials/custom-components/_index.md
new file mode 100644
index 000000000..5255b8270
--- /dev/null
+++ b/content/develop/tutorials/custom-components/_index.md
@@ -0,0 +1,30 @@
+---
+title: Build custom components
+slug: /develop/tutorials/custom-components
+description: Step-by-step tutorials for building Streamlit custom components with the official component template.
+keywords: custom components v2, tutorials, TypeScript, React, template, package-based
+---
+
+# Build custom components
+
+Build package-based Streamlit custom components using the official [component template](https://github.com/streamlit/component-template). These tutorials walk you through generating a project, understanding the generated code, and extending the template.
+
+
+
+
+
+
Create a component with Pure TypeScript
+
+Build a package-based component using Pure TypeScript and Vite. Best for lightweight components without framework overhead.
+
+
+
+
+
+
Create a component with React + TypeScript
+
+Build a package-based component using React, TypeScript, and Vite. Best for components with complex, state-driven UIs.
+
+
+
+
diff --git a/content/develop/tutorials/custom-components/template-react.md b/content/develop/tutorials/custom-components/template-react.md
new file mode 100644
index 000000000..eacdfc9a9
--- /dev/null
+++ b/content/develop/tutorials/custom-components/template-react.md
@@ -0,0 +1,721 @@
+---
+title: Create a component with React + TypeScript
+slug: /develop/tutorials/custom-components/template-react
+description: Build a package-based Streamlit custom component using the official template with React, TypeScript, and Vite.
+keywords: custom components v2, tutorial, React, TypeScript, template, cookiecutter, package-based, Vite, component development
+---
+
+# Create a component with React + TypeScript
+
+In this tutorial, you'll use the official [component template](https://github.com/streamlit/component-template) to generate a React-based custom component. You'll learn how React integrates with Streamlit's component lifecycle, how to manage the React root, and how to extend the template with React hooks and JSX.
+
+## Prerequisites
+
+- The following packages must be installed in your Python environment:
+ ```text hideHeader
+ streamlit>=1.51.0
+ uv
+ ```
+- Node.js 24 or later must be installed. This includes npm, the package manager for JavaScript.
+- Familiarity with [React](https://react.dev/) basics (components, hooks, JSX) is recommended.
+- Familiarity with [inline custom components](/develop/concepts/custom-components/components-v2/examples) is recommended.
+
+## Summary
+
+The template generates a working "Hello, World!" component with a click counter built using React. You'll walk through the generated code, then extend it to render a dynamic list of items from Python data.
+
+Here's a look at what you'll build:
+
+
+
+```none filename="Directory structure" hideCopyButton
+my-react-counter/
+├── pyproject.toml
+├── example.py
+└── my_react_counter/
+ ├── __init__.py
+ ├── pyproject.toml
+ └── frontend/
+ ├── package.json
+ ├── tsconfig.json
+ ├── vite.config.ts
+ └── src/
+ ├── index.tsx
+ └── MyComponent.tsx
+```
+
+```python filename="my_react_counter/__init__.py"
+import streamlit as st
+
+out = st.components.v2.component(
+ "my-react-counter.my_react_counter",
+ js="index-*.js",
+ html='',
+)
+
+def my_react_counter(name, items=None, key=None, on_item_clicked=lambda: None):
+ component_value = out(
+ key=key,
+ default={"num_clicks": 0, "selected_item": None},
+ data={"name": name, "items": items or []},
+ on_num_clicks_change=lambda: None,
+ on_selected_item_change=lambda: None,
+ on_item_clicked_change=on_item_clicked,
+ )
+ return component_value
+```
+
+```typescript filename="my_react_counter/frontend/src/index.tsx"
+import {
+ FrontendRenderer,
+ FrontendRendererArgs,
+} from "@streamlit/component-v2-lib";
+import { StrictMode } from "react";
+import { createRoot, Root } from "react-dom/client";
+
+import MyComponent, {
+ MyComponentDataShape,
+ MyComponentStateShape,
+} from "./MyComponent";
+
+const reactRoots: WeakMap =
+ new WeakMap();
+
+const MyComponentRoot: FrontendRenderer<
+ MyComponentStateShape,
+ MyComponentDataShape
+> = (args) => {
+ const { data, parentElement, setStateValue, setTriggerValue } = args;
+
+ const rootElement = parentElement.querySelector(".react-root");
+
+ if (!rootElement) {
+ throw new Error("Unexpected: React root element not found");
+ }
+
+ let reactRoot = reactRoots.get(parentElement);
+ if (!reactRoot) {
+ reactRoot = createRoot(rootElement);
+ reactRoots.set(parentElement, reactRoot);
+ }
+
+ const { name, items } = data;
+
+ reactRoot.render(
+
+
+ ,
+ );
+
+ return () => {
+ const reactRoot = reactRoots.get(parentElement);
+
+ if (reactRoot) {
+ reactRoot.unmount();
+ reactRoots.delete(parentElement);
+ }
+ };
+};
+
+export default MyComponentRoot;
+```
+
+```typescript filename="my_react_counter/frontend/src/MyComponent.tsx"
+import { FrontendRendererArgs } from "@streamlit/component-v2-lib";
+import { FC, ReactElement, useCallback, useState } from "react";
+
+export type MyComponentStateShape = {
+ num_clicks: number;
+ selected_item: string | null;
+ item_clicked: string | null;
+};
+
+export type MyComponentDataShape = {
+ name: string;
+ items: string[];
+};
+
+export type MyComponentProps = Pick<
+ FrontendRendererArgs,
+ "setStateValue" | "setTriggerValue"
+> &
+ MyComponentDataShape;
+
+const MyComponent: FC = ({
+ name,
+ items,
+ setStateValue,
+ setTriggerValue,
+}): ReactElement => {
+ const [numClicks, setNumClicks] = useState(0);
+ const [selectedItem, setSelectedItem] = useState(null);
+
+ const onClicked = useCallback((): void => {
+ const newNumClicks = numClicks + 1;
+ setNumClicks(newNumClicks);
+ setStateValue("num_clicks", newNumClicks);
+ }, [numClicks, setStateValue]);
+
+ const onItemSelected = useCallback(
+ (item: string): void => {
+ setSelectedItem(item);
+ setStateValue("selected_item", item);
+ setTriggerValue("item_clicked", item);
+ },
+ [setStateValue, setTriggerValue],
+ );
+
+ return (
+
+ );
+};
+
+export default MyComponent;
+```
+
+```python filename="example.py"
+import streamlit as st
+from my_react_counter import my_component
+
+st.title("My React Counter")
+
+result = my_component(
+ "Streamlit",
+ items=["Python", "TypeScript", "React", "Vite"],
+ key="counter",
+)
+
+st.write(f"Click count: {result.num_clicks}")
+if result.selected_item:
+ st.write(f"Selected: {result.selected_item}")
+if result.item_clicked:
+ st.write(f"Just clicked: {result.item_clicked}")
+```
+
+
+
+## Generate the project
+
+1. Navigate to the directory where you want to create your project and run the cookiecutter generator. The generator will create a new subdirectory for your project.
+
+ ```bash
+ uvx --from cookiecutter cookiecutter gh:streamlit/component-template --directory cookiecutter/v2
+ ```
+
+1. Follow the interactive prompts. When asked for the framework, select **React + Typescript**:
+
+ ```shell
+ [1/8] author_name (John Smith): Your Name
+ [2/8] author_email (john@example.com): you@example.com
+ [3/8] project_name (Streamlit Component X): My React Counter
+ [4/8] package_name (streamlit-component-x): my-react-counter
+ [5/8] import_name (streamlit_component_x): my_react_counter
+ [6/8] description (Streamlit component that allows you to do X): A React-based counter component
+ [7/8] Select open_source_license
+ ...
+ Choose from [1/2/3/4/5/6](1): 1
+ [8/8] Select framework
+ 1 - React + Typescript
+ 2 - Pure Typescript
+ Choose from [1/2] (1): 1
+ ```
+
+ This creates a `my-react-counter/` directory with the following structure:
+
+ ```none hideHeader
+ my-react-counter/
+ ├── example.py
+ ├── LICENSE
+ ├── MANIFEST.in
+ ├── pyproject.toml
+ ├── README.md
+ └── my_react_counter/
+ ├── __init__.py
+ ├── pyproject.toml
+ └── frontend/
+ ├── package.json
+ ├── tsconfig.json
+ ├── vite.config.ts
+ └── src/
+ ├── index.tsx
+ ├── MyComponent.tsx
+ └── vite-env.d.ts
+ ```
+
+ Notice the React template has two frontend source files instead of one: `index.tsx` handles integration with Streamlit's lifecycle, and `MyComponent.tsx` contains the React component. This is a convention but not a requirement. You can have a single source file or arbitrarily many source files.
+
+## Run the template
+
+You need two terminals running in parallel for development. The following steps use `uv run` to run commands inside the project's virtual environment. If a `.venv` doesn't exist yet, `uv run` creates one automatically.
+
+1. In the first terminal, navigate to the frontend directory, install dependencies, and start the dev build watcher:
+
+ ```bash
+ cd my-react-counter/my_react_counter/frontend
+ npm install
+ npm run dev
+ ```
+
+1. In a second terminal, navigate to the project root and run the example app:
+
+ ```bash
+ cd my-react-counter
+ uv run streamlit run example.py
+ ```
+
+1. View your running app.
+
+ You should see a "Hello, World!" heading with a "Click Me!" button. Clicking the button increments a counter that's sent back to Python. An `st.text_input` lets you specify a name which is passed to a second instance of the component.
+
+## Understand the generated code
+
+Now that the component is running, walk through each file to understand how it works.
+
+1. Open `my_react_counter/__init__.py`:
+
+ ```python
+ import streamlit as st
+
+ out = st.components.v2.component(
+ "my-react-counter.my_react_counter",
+ js="index-*.js",
+ html='',
+ )
+
+
+ def on_num_clicks_change():
+ pass
+
+
+ def my_react_counter(name, key=None):
+ component_value = out(
+ name=name,
+ key=key,
+ default={"num_clicks": 0},
+ data={"name": name},
+ on_num_clicks_change=on_num_clicks_change,
+ )
+
+ return component_value
+ ```
+
+ This file does two things:
+ - **Registers the component** with `st.components.v2.component()`. The first argument is a qualified name (`"."`) where `` matches the `name` field in the project-level `pyproject.toml` and `` matches the `name` field in the component-level `pyproject.toml`. The other two arguments point to the frontend assets: `js` is a glob pattern that matches the JavaScript bundle produced by Vite. `html` provides the root `
` that React mounts into.
+
+ - **Defines a wrapper function** (`my_react_counter`) that provides a clean API. The wrapper calls the raw component with `data`, `default`, and callback parameters. This pattern is optional but recommended. For more about these parameters, see [Component mounting](/develop/concepts/custom-components/components-v2/mount).
+
+1. Open `my_react_counter/frontend/src/index.tsx`:
+
+ ```typescript
+ import {
+ FrontendRenderer,
+ FrontendRendererArgs,
+ } from "@streamlit/component-v2-lib";
+ import { StrictMode } from "react";
+ import { createRoot, Root } from "react-dom/client";
+
+ import MyComponent, {
+ MyComponentDataShape,
+ MyComponentStateShape,
+ } from "./MyComponent";
+
+ const reactRoots: WeakMap =
+ new WeakMap();
+
+ const MyComponentRoot: FrontendRenderer<
+ MyComponentStateShape,
+ MyComponentDataShape
+ > = (args) => {
+ const { data, parentElement, setStateValue } = args;
+
+ const rootElement = parentElement.querySelector(".react-root");
+
+ if (!rootElement) {
+ throw new Error("Unexpected: React root element not found");
+ }
+
+ let reactRoot = reactRoots.get(parentElement);
+ if (!reactRoot) {
+ reactRoot = createRoot(rootElement);
+ reactRoots.set(parentElement, reactRoot);
+ }
+
+ const { name } = data;
+
+ reactRoot.render(
+
+
+ ,
+ );
+
+ return () => {
+ const reactRoot = reactRoots.get(parentElement);
+
+ if (reactRoot) {
+ reactRoot.unmount();
+ reactRoots.delete(parentElement);
+ }
+ };
+ };
+
+ export default MyComponentRoot;
+ ```
+
+ This file bridges Streamlit's component lifecycle and React. Because Streamlit calls your `FrontendRenderer` function on every re-render (whenever `data` changes), the pattern is different from a typical React app:
+ - **React root management**: You can't create a new React root each time Streamlit calls your function because that would destroy React state on every update. Instead, the `WeakMap` stores one root per component instance, keyed by `parentElement`. On the first call, it creates the root. On subsequent calls, it re-renders into the existing root. This also means multiple instances of the same component in an app each get their own independent React root with their own state.
+ - **Module-level scope**: Code outside `MyComponentRoot`, like the `WeakMap` declaration, runs once when the module loads and is shared across all component instances. If you need one-time global setup. like initializing a third-party library, put it at the module level so it's done once rather than repeated per instance or per re-render.
+ - **Passing props**: `MyComponentRoot` extracts `data` and `setStateValue` from Streamlit's args and passes them as React props to `MyComponent`. This is where you decide which Streamlit args your React component needs.
+ - **Cleanup**: The returned function unmounts the React root when Streamlit removes the component from the page.
+
+1. Open `my_react_counter/frontend/src/MyComponent.tsx`:
+
+ ```typescript
+ import { FrontendRendererArgs } from "@streamlit/component-v2-lib";
+ import {
+ CSSProperties,
+ FC,
+ ReactElement,
+ useCallback,
+ useMemo,
+ useState,
+ } from "react";
+
+ export type MyComponentStateShape = {
+ num_clicks: number;
+ };
+
+ export type MyComponentDataShape = {
+ name: string;
+ };
+
+ export type MyComponentProps = Pick<
+ FrontendRendererArgs,
+ "setStateValue"
+ > &
+ MyComponentDataShape;
+
+ const MyComponent: FC = ({
+ name,
+ setStateValue,
+ }): ReactElement => {
+ const [isFocused, setIsFocused] = useState(false);
+ const [numClicks, setNumClicks] = useState(0);
+
+ const style = useMemo(() => {
+ const colorToUse = isFocused
+ ? "var(--st-primary-color)"
+ : "var(--st-gray-color)";
+
+ const borderStyling = `1px solid ${colorToUse}`;
+
+ return {
+ border: borderStyling,
+ outline: borderStyling,
+ };
+ }, [isFocused]);
+
+ const onClicked = useCallback((): void => {
+ const newNumClicks = numClicks + 1;
+ setNumClicks(newNumClicks);
+
+ setStateValue("num_clicks", newNumClicks);
+ }, [numClicks, setStateValue]);
+
+ const onFocus = useCallback((): void => {
+ setIsFocused(true);
+ }, []);
+
+ const onBlur = useCallback((): void => {
+ setIsFocused(false);
+ }, []);
+
+ return (
+
+
Hello, {name}!
+
+
+ );
+ };
+
+ export default MyComponent;
+ ```
+
+ This is a standard React functional component:
+ - **Type-safe props**: `MyComponentProps` is constructed from `FrontendRendererArgs` using TypeScript's `Pick` utility type. This ensures the `setStateValue` prop is correctly typed for the component's state shape.
+ - **React state management**: Local UI state (like `isFocused`) is managed with React's `useState` hook. This state is purely for the frontend and doesn't need to go back to Python.
+ - **Communicating with Python**: When the button is clicked, `setStateValue("num_clicks", newNumClicks)` sends the count back to Streamlit. This triggers a Python rerun, just like in non-React components.
+ - **Streamlit theming**: The component uses CSS custom properties like `var(--st-primary-color)` directly in inline styles. These properties are provided by Streamlit's theme system and work inside the component's shadow DOM.
+
+## Modify the component
+
+Now extend the template to render a dynamic list of items from Python data. This showcases something React does well: declaratively rendering lists with state.
+
+1. In `my_react_counter/frontend/src/MyComponent.tsx`, make the following changes to add list rendering and item selection:
+
+
+
+ The copy button on the diff code blocks only copy the lines in the final result, not the deleted lines.
+
+
+
+ ```diff-typescript
+ =import { FrontendRendererArgs } from "@streamlit/component-v2-lib";
+ -import {
+ - CSSProperties,
+ - FC,
+ - ReactElement,
+ - useCallback,
+ - useMemo,
+ - useState,
+ -} from "react";
+ +import { FC, ReactElement, useCallback, useState } from "react";
+ =
+ =export type MyComponentStateShape = {
+ = num_clicks: number;
+ + selected_item: string | null;
+ + item_clicked: string | null;
+ =};
+ =
+ =export type MyComponentDataShape = {
+ = name: string;
+ + items: string[];
+ =};
+ =
+ =export type MyComponentProps = Pick<
+ = FrontendRendererArgs,
+ - "setStateValue"
+ + "setStateValue" | "setTriggerValue"
+ => &
+ = MyComponentDataShape;
+ =
+ =const MyComponent: FC = ({
+ = name,
+ + items,
+ = setStateValue,
+ + setTriggerValue,
+ =}): ReactElement => {
+ - const [isFocused, setIsFocused] = useState(false);
+ = const [numClicks, setNumClicks] = useState(0);
+ + const [selectedItem, setSelectedItem] = useState(null);
+ -
+ - const style = useMemo(() => {
+ - const colorToUse = isFocused
+ - ? "var(--st-primary-color)"
+ - : "var(--st-gray-color)";
+ -
+ - const borderStyling = `1px solid ${colorToUse}`;
+ -
+ - return {
+ - border: borderStyling,
+ - outline: borderStyling,
+ - };
+ - }, [isFocused]);
+ =
+ = const onClicked = useCallback((): void => {
+ = const newNumClicks = numClicks + 1;
+ = setNumClicks(newNumClicks);
+ = setStateValue("num_clicks", newNumClicks);
+ = }, [numClicks, setStateValue]);
+ =
+ - const onFocus = useCallback((): void => {
+ - setIsFocused(true);
+ - }, []);
+ -
+ - const onBlur = useCallback((): void => {
+ - setIsFocused(false);
+ - }, []);
+ -
+ - return (
+ -
+ -
+ + );
+ =};
+ =
+ =export default MyComponent;
+ ```
+
+1. In `my_react_counter/frontend/src/index.tsx`, make the following changes to pass the new props:
+
+ ```diff-typescript
+ => = (args) => {
+ - const { data, parentElement, setStateValue } = args;
+ + const { data, parentElement, setStateValue, setTriggerValue } = args;
+ ```
+
+ ```diff-typescript
+ - const { name } = data;
+ + const { name, items } = data;
+ =
+ = reactRoot.render(
+ =
+ -
+ +
+ = ,
+ = );
+ ```
+
+1. In `my_react_counter/__init__.py`, make the following changes to pass items and handle the new callbacks:
+
+ ```diff-python
+ -def on_num_clicks_change():
+ - pass
+ -
+ -
+ -def my_react_counter(name, key=None):
+ +def my_react_counter(name, items=None, key=None, on_item_clicked=lambda: None):
+ = component_value = out(
+ - name=name,
+ = key=key,
+ - default={"num_clicks": 0},
+ - data={"name": name},
+ - on_num_clicks_change=on_num_clicks_change,
+ + default={"num_clicks": 0, "selected_item": None},
+ + data={"name": name, "items": items or []},
+ + on_num_clicks_change=lambda: None,
+ + on_selected_item_change=lambda: None,
+ + on_item_clicked_change=on_item_clicked,
+ = )
+ = return component_value
+ ```
+
+ The wrapper now accepts `items` and an `on_item_clicked` callback (defaulting to `lambda: None`). Inside, `on_num_clicks_change` and `on_selected_item_change` use inline lambdas since nothing needs to happen for those events. `on_item_clicked_change` passes through the caller's callback so the app can react when an item is clicked.
+
+1. To exercise the new list feature, replace the contents of `example.py` with the following:
+
+ ```python
+ import streamlit as st
+ from my_react_counter import my_react_counter
+
+ st.title("My React Counter")
+
+ result = my_react_counter(
+ "Streamlit",
+ items=["Python", "TypeScript", "React", "Vite"],
+ key="counter",
+ )
+
+ st.write(f"Click count: {result.num_clicks}")
+ if result.selected_item:
+ st.write(f"Selected: {result.selected_item}")
+ if result.item_clicked:
+ st.write(f"Just clicked: {result.item_clicked}")
+ ```
+
+1. If `npm run dev` is still running, the frontend rebuilds automatically. Save your files, refresh your Streamlit app, and view the updated component with a clickable list.
+
+## Build for production
+
+When you're ready to share your component, create a production build.
+
+1. Stop the `npm run dev` watcher and the `streamlit run` process by pressing `Ctrl+C` in each terminal.
+
+1. In either terminal, navigate to the frontend directory and build the frontend:
+
+ ```bash
+ cd my-react-counter/my_react_counter/frontend
+ npm run build
+ ```
+
+1. Navigate to the project root and build the Python wheel:
+
+ ```bash
+ cd ../..
+ uv build
+ ```
+
+ This creates a `.whl` file in the `dist/` directory that you can distribute or upload to PyPI. For publishing instructions, see [Publish a Component](/develop/concepts/custom-components/publish).
+
+## What's next?
+
+- Learn more about the project structure in [Package-based components](/develop/concepts/custom-components/components-v2/package-based).
+- Understand [State vs trigger values](/develop/concepts/custom-components/components-v2/state-and-triggers) for interactive components.
+- Explore [Theming and styling](/develop/concepts/custom-components/components-v2/theming) to use Streamlit's CSS custom properties.
+- Try the [Pure TypeScript tutorial](/develop/tutorials/custom-components/template-typescript) if you want a lighter-weight approach without React.
diff --git a/content/develop/tutorials/custom-components/template-typescript.md b/content/develop/tutorials/custom-components/template-typescript.md
new file mode 100644
index 000000000..5f957146e
--- /dev/null
+++ b/content/develop/tutorials/custom-components/template-typescript.md
@@ -0,0 +1,548 @@
+---
+title: Create a component with Pure TypeScript
+slug: /develop/tutorials/custom-components/template-typescript
+description: Build a package-based Streamlit custom component using the official template with Pure TypeScript and Vite.
+keywords: custom components v2, tutorial, TypeScript, template, cookiecutter, package-based, Vite, component development
+---
+
+# Create a component with Pure TypeScript
+
+In this tutorial, you'll use the official [component template](https://github.com/streamlit/component-template) to generate a package-based custom component, understand how each piece works, and modify the component to make it your own.
+
+## Prerequisites
+
+- The following packages must be installed in your Python environment:
+ ```text hideHeader
+ streamlit>=1.51.0
+ uv
+ ```
+- Node.js 24 or later must be installed. This includes npm, the package manager for JavaScript.
+- Familiarity with [inline custom components](/develop/concepts/custom-components/components-v2/examples) is recommended.
+
+## Summary
+
+The template generates a working "Hello, World!" component with a click counter. You'll walk through the generated code, then extend it to add a reset button with a trigger value.
+
+Here's a look at what you'll build:
+
+
+
+```none filename="Directory structure" hideCopyButton
+my-click-counter/
+├── pyproject.toml
+├── example.py
+└── my_click_counter/
+ ├── __init__.py
+ ├── pyproject.toml
+ └── frontend/
+ ├── package.json
+ ├── tsconfig.json
+ ├── vite.config.ts
+ └── src/
+ └── index.ts
+```
+
+```python filename="my_click_counter/__init__.py"
+import streamlit as st
+
+out = st.components.v2.component(
+ "my-click-counter.my_click_counter",
+ js="index-*.js",
+ html="""
+
+
+
+
+
+
+
+
+
+
+ """,
+)
+
+
+def my_click_counter(name, key=None, on_reset=lambda: None):
+ component_value = out(
+ name=name,
+ key=key,
+ default={"num_clicks": 0},
+ data={"name": name},
+ on_num_clicks_change=lambda: None,
+ on_was_reset_change=on_reset,
+ )
+ return component_value
+```
+
+```typescript filename="my_click_counter/frontend/src/index.ts"
+import {
+ FrontendRenderer,
+ FrontendRendererArgs,
+} from "@streamlit/component-v2-lib";
+
+export type FrontendState = {
+ num_clicks: number;
+ was_reset: boolean;
+};
+
+export type ComponentData = {
+ name: string;
+};
+
+const instances: WeakMap<
+ FrontendRendererArgs["parentElement"],
+ { numClicks: number }
+> = new WeakMap();
+
+const MyComponent: FrontendRenderer = (args) => {
+ const { parentElement, data, setStateValue, setTriggerValue } = args;
+
+ const rootElement = parentElement.querySelector(".component-root");
+ if (!rootElement) {
+ throw new Error("Unexpected: root element not found");
+ }
+
+ const heading = rootElement.querySelector("h1");
+ if (heading) {
+ heading.textContent = `Hello, ${data.name}!`;
+ }
+
+ const incrementBtn =
+ rootElement.querySelector("#increment");
+ const resetBtn = rootElement.querySelector("#reset");
+ const countDisplay = rootElement.querySelector("#count");
+
+ if (!incrementBtn || !resetBtn || !countDisplay) {
+ throw new Error("Unexpected: required elements not found");
+ }
+
+ const currentCount = instances.get(parentElement)?.numClicks || 0;
+ countDisplay.textContent = `Clicks: ${currentCount}`;
+ const handleIncrement = () => {
+ const numClicks = (instances.get(parentElement)?.numClicks || 0) + 1;
+ instances.set(parentElement, { numClicks });
+ countDisplay.textContent = `Clicks: ${numClicks}`;
+ setStateValue("num_clicks", numClicks);
+ };
+
+ const handleReset = () => {
+ instances.set(parentElement, { numClicks: 0 });
+ countDisplay.textContent = `Clicks: 0`;
+ setStateValue("num_clicks", 0);
+ setTriggerValue("was_reset", true);
+ };
+
+ if (!instances.has(parentElement)) {
+ incrementBtn.addEventListener("click", handleIncrement);
+ resetBtn.addEventListener("click", handleReset);
+ instances.set(parentElement, { numClicks: 0 });
+ }
+
+ return () => {
+ incrementBtn.removeEventListener("click", handleIncrement);
+ resetBtn.removeEventListener("click", handleReset);
+ instances.delete(parentElement);
+ };
+};
+
+export default MyComponent;
+```
+
+```python filename="example.py"
+import streamlit as st
+from my_click_counter import my_click_counter
+
+st.title("My Click Counter")
+
+def handle_reset():
+ st.toast("Counter was reset!")
+
+result = my_click_counter("Streamlit", key="counter", on_reset=handle_reset)
+
+st.write(f"Click count: {result.num_clicks}")
+if result.was_reset:
+ st.write("The counter was just reset.")
+```
+
+
+
+## Generate the project
+
+1. Navigate to the directory where you want to create your project and run the cookiecutter generator. The generator will create a new subdirectory for your project.
+
+ ```bash
+ uvx --from cookiecutter cookiecutter gh:streamlit/component-template --directory cookiecutter/v2
+ ```
+
+1. Follow the interactive prompts. When asked for the framework, select **Pure Typescript**:
+
+ ```shell
+ [1/8] author_name (John Smith): Your Name
+ [2/8] author_email (john@example.com): you@example.com
+ [3/8] project_name (Streamlit Component X): My Click Counter
+ [4/8] package_name (streamlit-component-x): my-click-counter
+ [5/8] import_name (streamlit_component_x): my_click_counter
+ [6/8] description (Streamlit component that allows you to do X): A click counter component
+ [7/8] Select open_source_license
+ ...
+ Choose from [1/2/3/4/5/6](1): 1
+ [8/8] Select framework
+ 1 - React + Typescript
+ 2 - Pure Typescript
+ Choose from [1/2] (1): 2
+ ```
+
+ This creates a `my-click-counter/` directory with the following structure:
+
+ ```none hideHeader
+ my-click-counter/
+ ├── example.py
+ ├── LICENSE
+ ├── MANIFEST.in
+ ├── pyproject.toml
+ ├── README.md
+ └── my_click_counter/
+ ├── __init__.py
+ ├── pyproject.toml
+ └── frontend/
+ ├── package.json
+ ├── tsconfig.json
+ ├── vite.config.ts
+ └── src/
+ ├── index.ts
+ └── vite-env.d.ts
+ ```
+
+## Run the template
+
+You need two terminals running in parallel for development. The following steps use `uv run` to run commands inside the project's virtual environment. If a `.venv` doesn't exist yet, `uv run` creates one automatically.
+
+1. In the first terminal, navigate to the frontend directory, install dependencies, and start the dev build watcher:
+
+ ```bash
+ cd my-click-counter/my_click_counter/frontend
+ npm install
+ npm run dev
+ ```
+
+1. In a second terminal, navigate to the project root and run the example app:
+
+ ```bash
+ cd my-click-counter
+ uv run streamlit run example.py
+ ```
+
+1. View your running app.
+
+ You should see a "Hello, World!" heading with a "Click Me!" button. Clicking the button increments a counter that's sent back to Python. An `st.text_input` lets you specify a name which is passed to a second instance of the component.
+
+## Understand the generated code
+
+Now that the component is running, walk through each file to understand how it works.
+
+1. Open `my_click_counter/__init__.py`:
+
+ ```python
+ import streamlit as st
+
+ out = st.components.v2.component(
+ "my-click-counter.my_click_counter",
+ js="index-*.js",
+ html="""
+
+
+
+
+
+
+ """,
+ )
+
+
+ def on_num_clicks_change():
+ pass
+
+
+ def my_click_counter(name, key=None):
+ component_value = out(
+ name=name,
+ key=key,
+ default={"num_clicks": 0},
+ data={"name": name},
+ on_num_clicks_change=on_num_clicks_change,
+ )
+
+ return component_value
+ ```
+
+ This file does two things:
+ - **Registers the component** with `st.components.v2.component()`. The first argument is a qualified name (`"."`) where `` matches the `name` field in the project-level `pyproject.toml` and `` matches the `name` field in the component-level `pyproject.toml`. The other two arguments point to the frontend assets: `js` is a glob pattern that matches the JavaScript bundle produced by Vite. `html` provides the initial markup that's rendered before the JavaScript loads.
+
+ - **Defines a wrapper function** (`my_click_counter`) that provides a clean API. The wrapper calls the raw component with `data`, `default`, and callback parameters. This pattern is optional but recommended. For more about these parameters, see [Component mounting](/develop/concepts/custom-components/components-v2/mount).
+
+1. Open `my_click_counter/frontend/src/index.ts`:
+
+ ```typescript
+ import {
+ FrontendRenderer,
+ FrontendRendererArgs,
+ } from "@streamlit/component-v2-lib";
+
+ export type FrontendState = {
+ num_clicks: number;
+ };
+
+ export type ComponentData = {
+ name: string;
+ };
+
+ const instances: WeakMap<
+ FrontendRendererArgs["parentElement"],
+ { numClicks: number }
+ > = new WeakMap();
+
+ const MyComponent: FrontendRenderer = (
+ args,
+ ) => {
+ const { parentElement, data, setStateValue } = args;
+
+ const rootElement = parentElement.querySelector(".component-root");
+ if (!rootElement) {
+ throw new Error("Unexpected: root element not found");
+ }
+
+ const heading = rootElement.querySelector("h1");
+ if (heading) {
+ heading.textContent = `Hello, ${data.name}!`;
+ }
+
+ const button = rootElement.querySelector("button");
+ if (!button) {
+ throw new Error("Unexpected: button element not found");
+ }
+
+ const handleClick = () => {
+ const numClicks = (instances.get(parentElement)?.numClicks || 0) + 1;
+ instances.set(parentElement, { numClicks });
+ setStateValue("num_clicks", numClicks);
+ };
+
+ if (!instances.has(parentElement)) {
+ button.addEventListener("click", handleClick);
+ instances.set(parentElement, { numClicks: 0 });
+ }
+
+ return () => {
+ button.removeEventListener("click", handleClick);
+ instances.delete(parentElement);
+ };
+ };
+
+ export default MyComponent;
+ ```
+
+ This follows the same pattern as inline components, but with TypeScript types. Here are the key pieces:
+ - **Type definitions**: `FrontendState` and `ComponentData` define the shape of the component's state and the data it receives from Python. These are used as generic parameters on `FrontendRenderer` for type safety.
+ - **Instance tracking**: The `WeakMap` tracks per-instance state (the click count) across re-renders. Since Streamlit calls your function on every re-render, you need a way to persist state between calls without re-adding event listeners.
+ - **`setStateValue`**: Sends the updated click count back to Python. This triggers a rerun, just like in inline components.
+ - **Cleanup function**: The returned function removes event listeners when the component is unmounted.
+
+The `vite.config.ts` builds your TypeScript into an ES module with a hashed filename (like `index-a1b2c3d4.js`). The `pyproject.toml` files tell setuptools to include these build artifacts in the Python package, and tell Streamlit where to find and serve them. For a detailed explanation of each configuration file, see [Package-based components](/develop/concepts/custom-components/components-v2/package-based#understanding-the-project-structure).
+
+## Modify the component
+
+You can extend the template to add a reset button and a trigger value that fires when the counter is reset.
+
+1. In `my_click_counter/__init__.py`, make the following changes to the `html` parameter to add a reset button and a count display:
+
+ ```diff-python
+ = html="""
+ =
+ =
+ =
+ -
+ +
+ +
+ +
+ +
+ +
+ =
+ =
+ = """,
+ ```
+
+
+
+ The copy button on the diff code blocks only copy the lines in the final result, not the deleted lines.
+
+
+
+1. In `my_click_counter/frontend/src/index.ts`, make the following changes to handle both buttons:
+
+ ```diff-typescript
+ =import {
+ = FrontendRenderer,
+ = FrontendRendererArgs,
+ =} from "@streamlit/component-v2-lib";
+ =
+ =export type FrontendState = {
+ = num_clicks: number;
+ + was_reset: boolean;
+ =};
+ =
+ =export type ComponentData = {
+ = name: string;
+ =};
+ =
+ =const instances: WeakMap<
+ = FrontendRendererArgs["parentElement"],
+ = { numClicks: number }
+ => = new WeakMap();
+ =
+ =const MyComponent: FrontendRenderer = (
+ = args,
+ =) => {
+ - const { parentElement, data, setStateValue } = args;
+ + const { parentElement, data, setStateValue, setTriggerValue } = args;
+ =
+ = const rootElement = parentElement.querySelector(".component-root");
+ = if (!rootElement) {
+ = throw new Error("Unexpected: root element not found");
+ = }
+ =
+ = const heading = rootElement.querySelector("h1");
+ = if (heading) {
+ = heading.textContent = `Hello, ${data.name}!`;
+ = }
+ =
+ - const button = rootElement.querySelector("button");
+ - if (!button) {
+ - throw new Error("Unexpected: button element not found");
+ - }
+ + const incrementBtn =
+ + rootElement.querySelector("#increment");
+ + const resetBtn = rootElement.querySelector("#reset");
+ + const countDisplay = rootElement.querySelector("#count");
+ +
+ + if (!incrementBtn || !resetBtn || !countDisplay) {
+ + throw new Error("Unexpected: required elements not found");
+ + }
+ +
+ + const currentCount = instances.get(parentElement)?.numClicks || 0;
+ + countDisplay.textContent = `Clicks: ${currentCount}`;
+ -
+ - const handleClick = () => {
+ + const handleIncrement = () => {
+ = const numClicks = (instances.get(parentElement)?.numClicks || 0) + 1;
+ = instances.set(parentElement, { numClicks });
+ + countDisplay.textContent = `Clicks: ${numClicks}`;
+ = setStateValue("num_clicks", numClicks);
+ = };
+ +
+ + const handleReset = () => {
+ + instances.set(parentElement, { numClicks: 0 });
+ + countDisplay.textContent = `Clicks: 0`;
+ + setStateValue("num_clicks", 0);
+ + setTriggerValue("was_reset", true);
+ + };
+ =
+ = if (!instances.has(parentElement)) {
+ - button.addEventListener("click", handleClick);
+ + incrementBtn.addEventListener("click", handleIncrement);
+ + resetBtn.addEventListener("click", handleReset);
+ = instances.set(parentElement, { numClicks: 0 });
+ = }
+ =
+ = return () => {
+ - button.removeEventListener("click", handleClick);
+ + incrementBtn.removeEventListener("click", handleIncrement);
+ + resetBtn.removeEventListener("click", handleReset);
+ = instances.delete(parentElement);
+ = };
+ =};
+ =
+ =export default MyComponent;
+ ```
+
+ The key changes are:
+ - Added `was_reset` to the `FrontendState` type.
+ - Added `setTriggerValue` to the destructured args. Unlike `setStateValue`, trigger values are transient and reset to `None` after each rerun.
+ - Renamed the button to `incrementBtn` and the click handler to `handleIncrement`.
+ - Named the new button `resetBtn`.
+ - Added a reset handler, `handleReset`, that sets the count back to zero and fires a `"was_reset"` trigger.
+ - Added a count display that updates on each click.
+
+1. In `my_click_counter/__init__.py`, make the following changes to the wrapper function to handle the new trigger:
+
+ ```diff-python
+ -def on_num_clicks_change():
+ - pass
+ -
+ -
+ -def my_click_counter(name, key=None):
+ +def my_click_counter(name, key=None, on_reset=lambda: None):
+ = component_value = out(
+ = name=name,
+ = key=key,
+ = default={"num_clicks": 0},
+ = data={"name": name},
+ - on_num_clicks_change=on_num_clicks_change,
+ + on_num_clicks_change=lambda: None,
+ + on_was_reset_change=on_reset,
+ = )
+ = return component_value
+ ```
+
+ The wrapper now accepts an `on_reset` callback that defaults to `lambda: None`. Inside, `on_num_clicks_change` uses an inline lambda since nothing needs to happen when the count changes. `on_was_reset_change` passes through the caller's `on_reset` callback so the app can react when the counter is reset.
+
+1. If `npm run dev` is still running, the frontend rebuilds automatically. Refresh your Streamlit app to see the changes.
+
+1. To try the new functionality in a clean example, replace the contents of `example.py` with the following code:
+
+ ```python
+ import streamlit as st
+ from my_click_counter import my_click_counter
+
+ st.title("My Click Counter")
+
+ def handle_reset():
+ st.toast("Counter was reset!")
+
+ result = my_click_counter("Streamlit", key="counter", on_reset=handle_reset)
+
+ st.write(f"Click count: {result.num_clicks}")
+ if result.was_reset:
+ st.write("The counter was just reset.")
+ ```
+
+1. Save your file and view your running app.
+
+## Build for production
+
+When you're ready to share your component, create a production build.
+
+1. Stop the `npm run dev` watcher and the `streamlit run` process by pressing `Ctrl+C` in each terminal.
+
+1. In either terminal, navigate to the frontend directory and build the frontend:
+
+ ```bash
+ cd my-click-counter/my_click_counter/frontend
+ npm run build
+ ```
+
+1. Navigate to the project root and build the Python wheel:
+
+ ```bash
+ cd ../..
+ uv build
+ ```
+
+ This creates a `.whl` file in the `dist/` directory that you can distribute or upload to PyPI. For publishing instructions, see [Publish a Component](/develop/concepts/custom-components/publish).
+
+## What's next?
+
+- Learn more about the project structure in [Package-based components](/develop/concepts/custom-components/components-v2/package-based).
+- Understand [State vs trigger values](/develop/concepts/custom-components/components-v2/state-and-triggers) for interactive components.
+- Explore [Theming and styling](/develop/concepts/custom-components/components-v2/theming) to use Streamlit's CSS custom properties.
+- Try the [React + TypeScript tutorial](/develop/tutorials/custom-components/template-react) if you want to use React.
diff --git a/content/menu.md b/content/menu.md
index 1611c511a..23570aab6 100644
--- a/content/menu.md
+++ b/content/menu.md
@@ -671,6 +671,12 @@ site_menu:
url: /develop/tutorials/execution-flow/create-a-multiple-container-fragment
- category: Develop / Tutorials / Execution flow / Start and stop a streaming fragment
url: /develop/tutorials/execution-flow/start-and-stop-fragment-auto-reruns
+ - category: Develop / Tutorials / Build custom components
+ url: /develop/tutorials/custom-components
+ - category: Develop / Tutorials / Build custom components / Create a component with Pure TypeScript
+ url: /develop/tutorials/custom-components/template-typescript
+ - category: Develop / Tutorials / Build custom components / Create a component with React + TypeScript
+ url: /develop/tutorials/custom-components/template-react
- category: Develop / Tutorials / Multipage apps
url: /develop/tutorials/multipage
- category: Develop / Tutorials / Multipage apps / Dynamic navigation