import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';
A form lets your app ask users to input and submit data. Forms can be defined with a simple form object that takes a list of fields, and return user responses directly as promises.
Promise-based forms:
import { showForm } from '@devvit/web/client';
// Show form and get user response directly
const result = await showForm({
form: {
fields: [
{
type: 'string',
name: 'name',
label: 'Name',
},
],
},
data: { name: 'Default value' } // Optional initial data
});
// Handle form submission result immediately
if (result) {
const { name } = result;
// Process the data directly
console.log(`User entered: ${name}`);
// Chain additional actions
await fetch('/api/save-name', {
method: 'POST',
body: JSON.stringify({ name })
});
// Or show another form in sequence
const step2 = await showForm({
form: {
fields: [
{
type: 'string',
name: 'food',
label: 'Favorite food?',
},
],
}
});
if (step2) {
console.log(`Multi-step complete: ${name}, ${step2.food}`);
}
} else {
console.log('User cancelled the form');
}showForm(options) → Returns Promise
form(Form): The form specification objectdata(FormValues, optional): Initial form field values- Returns:
Promise<FormValues | null>- Resolves with form data or null if cancelled
For forms that open from a menu item, you can use menu responses. This is useful since you do not have access to the @devvit/web/client library from a menu item endpoint.
Configure forms in devvit.json:
{
"forms": {
"nameForm": "/internal/form/name-submit",
"reviewForm": "/internal/form/review-submit"
}
}Server endpoint that shows form via menu response:
<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>
import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';
type NameFormRequest = { name: string };
type ReviewFormRequest = { review: string };
// Menu action that triggers menu response form
app.post('/internal/menu/start-workflow', async (c) => {
const _input = await c.req.json<MenuItemRequest>();
// Server processing before showing form
const userData = await fetchUserData();
return c.json<UiResponse>({
showForm: {
name: 'nameForm',
form: {
fields: [
{
type: 'string',
name: 'name',
label: 'Name',
},
],
},
data: { name: userData.name } // Pre-populate from server
}
});
});
// Form submission handler that can chain to another form
app.post('/internal/form/name-submit', async (c) => {
const { name } = await c.req.json<NameFormRequest>();
// Server processing
await saveUserName(name);
// Show next form in workflow
return c.json<UiResponse>({
showForm: {
name: 'reviewForm',
form: {
fields: [
{
type: 'paragraph',
name: 'review',
label: 'How was your experience?',
},
],
}
}
});
});
app.post('/internal/form/review-submit', async (c) => {
const { review } = await c.req.json<ReviewFormRequest>();
await saveReview(review);
return c.json<UiResponse>({
showToast: 'Thank you for your feedback!'
});
});import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';
type NameFormRequest = { name: string };
type ReviewFormRequest = { review: string };
// Menu action that triggers menu response form
router.post<string, never, UiResponse, MenuItemRequest>("/internal/menu/start-workflow", async (_req, res) => {
// Server processing before showing form
const userData = await fetchUserData();
res.json({
showForm: {
name: 'nameForm',
form: {
fields: [
{
type: 'string',
name: 'name',
label: 'Name',
},
],
},
data: { name: userData.name } // Pre-populate from server
}
});
});
// Form submission handler that can chain to another form
router.post<string, never, UiResponse, NameFormRequest>("/internal/form/name-submit", async (req, res) => {
const { name } = req.body;
// Server processing
await saveUserName(name);
// Show next form in workflow
res.json({
showForm: {
name: 'reviewForm',
form: {
fields: [
{
type: 'paragraph',
name: 'review',
label: 'How was your experience?',
},
],
}
}
});
});
router.post<string, never, UiResponse, ReviewFormRequest>("/internal/form/review-submit", async (req, res) => {
const { review } = req.body;
await saveReview(review);
res.json({
showToast: 'Thank you for your feedback!'
});
});The form object enables you to customize the form container and the list of form fields included.
const myForm = {
title: 'My form',
description: 'This is my form. There are many like it, but this one is mine.',
fields: [
{
type: 'string',
name: 'food',
label: 'What is your favorite food?',
},
{
type: 'string',
name: 'drink',
label: 'What is your favorite drink?',
},
],
acceptLabel: 'Submit',
cancelLabel: 'Cancel',
};| Property | Supported types | Description |
|---|---|---|
title |
string undefined |
An optional title for the form |
description |
string undefined |
An optional description for the form |
fields |
FormField[] |
The fields that will be displayed in the form |
acceptLabel |
string undefined |
An optional label for the submit button |
cancelLabel |
string undefined |
An optional label for the cancel button |
The following field types are supported: String, Select, Paragraph, Number, Boolean, Image, and Group.
A single-line text input.
const stringField = {
type: 'string',
name: 'title',
label: 'Tournament title',
};| Property | Supported types | Description |
|---|---|---|
type |
string |
The desired field type. |
name |
string |
The name of the field. This will be used as the key in the values object when the form is submitted. |
label |
string |
The label of the field. This will be displayed to the user. |
helpText |
string undefined |
An optional help text that will be displayed below the field. |
required |
boolean undefined |
If true the field will be required and the user will not be able to submit the form without filling it in. Defaults to false. |
disabled |
boolean undefined |
If true the field will be disabled. Defaults to false. |
defaultValue |
ValueType undefined |
The default value of the field. |
scope |
SettingScopeType undefined |
This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default. |
placeholder |
string undefined |
Placeholder text for display before a value is present. |
isSecret |
boolean undefined |
Makes the form field secret. |
A dropdown menu with predefined options.
const selectField = {
type: 'select',
name: 'interval',
label: 'Update the leaderboard',
options: [
{ label: 'Hourly', value: 'hourly' },
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Yearly', value: 'yearly' },
],
};| Property | Supported types | Description |
|---|---|---|
type |
string |
The desired field type. |
name |
string |
The name of the field. This will be used as the key in the values object when the form is submitted. |
label |
string |
The label of the field. This will be displayed to the user. |
options |
FieldConfig_Selection_Item[] |
The list of options available. |
helpText |
string undefined |
An optional help text that will be displayed below the field. |
required |
boolean undefined |
If true the field will be required and the user will not be able to submit the form without filling it in. Defaults to false. |
disabled |
boolean undefined |
If true the field will be disabled. Defaults to false. |
defaultValue |
string[] undefined |
The default value of the field. Note that the default value is wrapped in an array to support multiple selected values. |
scope |
SettingScopeType undefined |
This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default. |
multiSelect |
boolean undefined |
Enables users to select more than 1 item from the set. |
A multi-line text input for longer responses.
const paragraphField = {
type: 'paragraph',
name: 'description',
label: 'Description',
};| Property | Supported types | Description |
|---|---|---|
type |
string |
The desired field type. |
name |
string |
The name of the field. This will be used as the key in the values object when the form is submitted. |
label |
string |
The label of the field. This will be displayed to the user. |
helpText |
string undefined |
An optional help text that will be displayed below the field. |
required |
boolean undefined |
If true the field will be required and the user will not be able to submit the form without filling it in. Defaults to false. |
disabled |
boolean undefined |
If true the field will be disabled. Defaults to false. |
defaultValue |
ValueType undefined |
The default value of the field. |
scope |
SettingScopeType undefined |
This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default. |
placeholder |
string undefined |
Placeholder text for display before a value is present. |
lineHeight |
number undefined |
Sets the field height by number of lines. |
An input for numerical values.
const numberField = {
type: 'number',
name: 'tokens',
label: 'Token balance',
};| Property | Supported types | Description |
|---|---|---|
type |
string |
The desired field type. |
name |
string |
The name of the field. This will be used as the key in the values object when the form is submitted. |
label |
string |
The label of the field. This will be displayed to the user. |
helpText |
string undefined |
An optional help text that will be displayed below the field. |
required |
boolean undefined |
If true the field will be required and the user will not be able to submit the form without filling it in. Defaults to false. |
disabled |
boolean undefined |
If true the field will be disabled. Defaults to false. |
defaultValue |
ValueType undefined |
The default value of the field. |
scope |
SettingScopeType undefined |
This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default. |
A yes/no or true/false type input.
const booleanField = {
type: 'boolean',
name: 'enable',
label: 'Enable the event',
};| Property | Supported types | Description |
|---|---|---|
type |
string |
The desired field type. |
name |
string |
The name of the field. This will be used as the key in the values object when the form is submitted. |
label |
string |
The label of the field. This will be displayed to the user. |
helpText |
string undefined |
An optional help text that will be displayed below the field. |
disabled |
boolean undefined |
If true the field will be disabled. Defaults to false. |
defaultValue |
ValueType undefined |
The default value of the field. |
scope |
SettingScopeType undefined |
This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default. |
An image upload field.
const imageField = {
type: 'image', // This tells the form to expect an image
name: 'myImage',
label: 'Image goes here',
required: true,
};| Property | Supported types | Description |
|---|---|---|
type |
string |
The desired field type. |
name |
string |
The name of the field. This will be used as the key in the values object when the form is submitted. |
label |
string |
The label of the field. This will be displayed to the user. |
helpText |
string undefined |
An optional help text that will be displayed below the field. |
required |
boolean undefined |
If true the field will be required and the user will not be able to submit the form without filling it in. Defaults to false. |
disabled |
boolean undefined |
If true the field will be disabled. Defaults to false. |
scope |
SettingScopeType undefined |
This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default. |
placeholder |
string undefined |
Placeholder text for display before a value is present. |
isSecret |
boolean undefined |
Makes the form field secret. |
- The formats supported are PNG, JPEG, WEBP, and GIF.
- The maximum file size allowed is 20 MB.
- When uploading a WEBP image, it will be converted to JPEG. As such, the Reddit URL returned points to a JPEG image.
A collection of related fields that allows for better readability.
const groupField = {
type: 'group',
label: 'This is a group of input fields',
fields: [
{
type: 'paragraph',
name: 'description',
label: 'How would you describe what happened?',
},
{
type: 'number',
name: 'score',
label: 'How would you rate your meal on a scale from 1 to 10?',
},
],
};| Property | Supported types | Description |
|---|---|---|
type |
string |
The desired field type. |
label |
string |
The label of the group that will be displayed to the user. |
fields |
FormField[] |
The fields that will be displayed in the group. |
helpText |
string undefined |
An optional help text that will be displayed below the group. |
Below is a collection of common use cases and patterns.
Client-side approach:
import { showForm } from '@devvit/web/client';
// Get user data and show form with dynamic default values
const user = await reddit.getCurrentUser();
const result = await showForm({
form: {
fields: [
{
type: 'string',
name: 'username',
label: 'Username',
},
],
},
data: {
username: user?.username || ''
}
});
if (result) {
// Handle the form result
console.log(`Hello ${result.username}`);
}Server-side approach:
{
"forms": {
"dynamicForm": "/internal/form/dynamic-submit"
}
}<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>
import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';
type DynamicFormRequest = { username: string };
// Endpoint that shows form with dynamic data
app.post('/internal/menu/show-dynamic-form', async (c) => {
const _input = await c.req.json<MenuItemRequest>();
const user = await reddit.getCurrentUser();
return c.json<UiResponse>({
showForm: {
name: 'dynamicForm',
form: {
fields: [
{
type: 'string',
name: 'username',
label: 'Username',
},
],
},
data: {
username: user?.username || ''
}
}
});
});
// Form submission handler
app.post('/internal/form/dynamic-submit', async (c) => {
const { username } = await c.req.json<DynamicFormRequest>();
return c.json<UiResponse>({
showToast: `Hello ${username}`
});
});import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';
type DynamicFormRequest = { username: string };
// Endpoint that shows form with dynamic data
router.post<string, never, UiResponse, MenuItemRequest>("/internal/menu/show-dynamic-form", async (_req, res) => {
const user = await reddit.getCurrentUser();
res.json({
showForm: {
name: 'dynamicForm',
form: {
fields: [
{
type: 'string',
name: 'username',
label: 'Username',
},
],
},
data: {
username: user?.username || ''
}
}
});
});
// Form submission handler
router.post<string, never, UiResponse, DynamicFormRequest>("/internal/form/dynamic-submit", async (req, res) => {
const { username } = req.body;
res.json({
showToast: `Hello ${username}`
});
});Client-side approach (Promise chaining):
import { showForm } from '@devvit/web/client';
async function multiStepForm() {
// Step 1: Get name
const step1Result = await showForm({
form: {
fields: [
{
type: 'string',
name: 'name',
label: "What's your name?",
required: true,
},
],
}
});
if (!step1Result) return; // User cancelled
// Step 2: Get food preference
const step2Result = await showForm({
form: {
fields: [
{
type: 'string',
name: 'food',
label: "What's your favorite food?",
required: true,
},
],
},
data: { name: step1Result.name } // Pass data from previous step
});
if (!step2Result) return; // User cancelled
// Step 3: Get drink preference
const step3Result = await showForm({
form: {
fields: [
{
type: 'string',
name: 'drink',
label: "What's your favorite drink?",
required: true,
},
],
},
data: {
name: step1Result.name,
food: step2Result.food
}
});
if (step3Result) {
// All steps completed - save or process data
const finalData = {
...step1Result,
...step2Result,
...step3Result
};
console.log(`Thanks ${finalData.name}! You like ${finalData.food} and ${finalData.drink}.`);
}
}Server-side approach (Separate endpoints):
{
"forms": {
"step1Form": "/internal/form/step1-submit",
"step2Form": "/internal/form/step2-submit",
"step3Form": "/internal/form/step3-submit"
}
}<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>
import type { UiResponse } from '@devvit/web/shared';
type Step1FormRequest = { name: string };
type Step2FormRequest = { name: string; food: string };
type Step3FormRequest = { name: string; food: string; drink: string };
// Step 1: Name form
app.post('/internal/form/step1-submit', async (c) => {
const { name } = await c.req.json<Step1FormRequest>();
return c.json<UiResponse>({
showForm: {
name: 'step2Form',
form: {
fields: [
{
type: 'string',
name: 'food',
label: "What's your favorite food?",
required: true,
},
],
},
data: { name } // Pass data to next step
}
});
});
// Step 2: Food form
app.post('/internal/form/step2-submit', async (c) => {
const { name, food } = await c.req.json<Step2FormRequest>();
return c.json<UiResponse>({
showForm: {
name: 'step3Form',
form: {
fields: [
{
type: 'string',
name: 'drink',
label: "What's your favorite drink?",
required: true,
},
],
},
data: { name, food } // Pass accumulated data
}
});
});
// Step 3: Final form
app.post('/internal/form/step3-submit', async (c) => {
const { name, food, drink } = await c.req.json<Step3FormRequest>();
return c.json<UiResponse>({
showToast: `Thanks ${name}! You like ${food} and ${drink}.`
});
});import type { UiResponse } from '@devvit/web/shared';
type Step1FormRequest = { name: string };
type Step2FormRequest = { name: string; food: string };
type Step3FormRequest = { name: string; food: string; drink: string };
// Step 1: Name form
router.post<string, never, UiResponse, Step1FormRequest>("/internal/form/step1-submit", async (req, res) => {
const { name } = req.body;
res.json({
showForm: {
name: 'step2Form',
form: {
fields: [
{
type: 'string',
name: 'food',
label: "What's your favorite food?",
required: true,
},
],
},
data: { name } // Pass data to next step
}
});
});
// Step 2: Food form
router.post<string, never, UiResponse, Step2FormRequest>("/internal/form/step2-submit", async (req, res) => {
const { name, food } = req.body;
res.json({
showForm: {
name: 'step3Form',
form: {
fields: [
{
type: 'string',
name: 'drink',
label: "What's your favorite drink?",
required: true,
},
],
},
data: { name, food } // Pass accumulated data
}
});
});
// Step 3: Final form
router.post<string, never, UiResponse, Step3FormRequest>("/internal/form/step3-submit", async (req, res) => {
const { name, food, drink } = req.body;
res.json({
showToast: `Thanks ${name}! You like ${food} and ${drink}.`
});
});This example includes one of each of the supported field types.
Client-side approach:
import { showForm } from '@devvit/web/client';
const result = await showForm({
form: {
title: 'My favorites',
description: 'Tell us about your favorite food!',
fields: [
{
type: 'string',
name: 'food',
label: 'What is your favorite food?',
helpText: 'Must be edible',
required: true,
},
{
label: 'About that food',
type: 'group',
fields: [
{
type: 'number',
name: 'times',
label: 'How many times a week do you eat it?',
defaultValue: 1,
},
{
type: 'paragraph',
name: 'what',
label: 'What makes it your favorite?',
},
{
type: 'select',
name: 'healthy',
label: 'Is it healthy?',
options: [
{ label: 'Yes', value: 'yes' },
{ label: 'No', value: 'no' },
{ label: 'Maybe', value: 'maybe' },
],
defaultValue: ['maybe'],
},
],
},
{
type: 'boolean',
name: 'again',
label: 'Can we ask again?',
},
],
acceptLabel: 'Submit',
cancelLabel: 'Cancel',
}
});
if (result) {
console.log('Form values:', result);
// Handle form submission
}Server-side approach:
{
"forms": {
"everythingForm": "/internal/form/everything-submit"
}
}<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>
import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';
type EverythingFormRequest = {
food: string;
times?: number;
what?: string;
healthy?: string[];
again?: boolean;
};
app.post('/internal/form/everything-submit', async (c) => {
const formValues = await c.req.json<EverythingFormRequest>();
console.log('Form values:', formValues);
return c.json<UiResponse>({
showToast: 'Thanks!'
});
});
// Example showing the form
app.post('/internal/menu/show-everything-form', async (c) => {
const _input = await c.req.json<MenuItemRequest>();
return c.json<UiResponse>({
showForm: {
name: 'everythingForm',
form: {
title: 'My favorites',
description: 'Tell us about your favorite food!',
fields: [
{
type: 'string',
name: 'food',
label: 'What is your favorite food?',
helpText: 'Must be edible',
required: true,
},
{
label: 'About that food',
type: 'group',
fields: [
{
type: 'number',
name: 'times',
label: 'How many times a week do you eat it?',
defaultValue: 1,
},
{
type: 'paragraph',
name: 'what',
label: 'What makes it your favorite?',
},
{
type: 'select',
name: 'healthy',
label: 'Is it healthy?',
options: [
{ label: 'Yes', value: 'yes' },
{ label: 'No', value: 'no' },
{ label: 'Maybe', value: 'maybe' },
],
defaultValue: ['maybe'],
},
],
},
{
type: 'boolean',
name: 'again',
label: 'Can we ask again?',
},
],
acceptLabel: 'Submit',
cancelLabel: 'Cancel',
}
}
});
});import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';
type EverythingFormRequest = {
food: string;
times?: number;
what?: string;
healthy?: string[];
again?: boolean;
};
router.post<string, never, UiResponse, EverythingFormRequest>("/internal/form/everything-submit", async (req, res) => {
console.log('Form values:', req.body);
res.json({
showToast: 'Thanks!'
});
});
// Example showing the form
router.post<string, never, UiResponse, MenuItemRequest>("/internal/menu/show-everything-form", async (_req, res) => {
res.json({
showForm: {
name: 'everythingForm',
form: {
title: 'My favorites',
description: 'Tell us about your favorite food!',
fields: [
{
type: 'string',
name: 'food',
label: 'What is your favorite food?',
helpText: 'Must be edible',
required: true,
},
{
label: 'About that food',
type: 'group',
fields: [
{
type: 'number',
name: 'times',
label: 'How many times a week do you eat it?',
defaultValue: 1,
},
{
type: 'paragraph',
name: 'what',
label: 'What makes it your favorite?',
},
{
type: 'select',
name: 'healthy',
label: 'Is it healthy?',
options: [
{ label: 'Yes', value: 'yes' },
{ label: 'No', value: 'no' },
{ label: 'Maybe', value: 'maybe' },
],
defaultValue: ['maybe'],
},
],
},
{
type: 'boolean',
name: 'again',
label: 'Can we ask again?',
},
],
acceptLabel: 'Submit',
cancelLabel: 'Cancel',
}
}
});
});Client-side approach:
import { showForm } from '@devvit/web/client';
const result = await showForm({
form: {
title: 'Upload an image!',
fields: [
{
name: 'myImage',
type: 'image', // This tells the form to expect an image
label: 'Image goes here',
required: true,
},
],
}
});
if (result) {
const { myImage } = result;
// returns an i.redd.it URL
console.log('Image uploaded:', myImage);
// Process the image further
await fetch('/api/process-image', {
method: 'POST',
body: JSON.stringify({ imageUrl: myImage })
});
}Server-side approach:
{
"forms": {
"imageForm": "/internal/form/image-submit"
}
}<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>
import type { UiResponse } from '@devvit/web/shared';
type ImageFormRequest = { myImage: string };
app.post('/internal/form/image-submit', async (c) => {
const { myImage } = await c.req.json<ImageFormRequest>();
// Store the mediaUrl in Redis and render it via an <img> tag on the client, or send to external service to modify
console.log('Image uploaded:', myImage);
return c.json<UiResponse>({
showToast: 'Image uploaded successfully!'
});
});import type { UiResponse } from '@devvit/web/shared';
type ImageFormRequest = { myImage: string };
router.post<string, never, UiResponse, ImageFormRequest>("/internal/form/image-submit", async (req, res) => {
const { myImage } = req.body;
// Store the mediaUrl in Redis and render it via an <img> tag on the client, or send to external service to modify
console.log('Image uploaded:', myImage);
res.json({
showToast: 'Image uploaded successfully!'
});
});





