Slack apps in 2026 are event-driven, Block Kit–composed, and almost always built with Bolt — Slack's official framework. The combination of Bolt's middleware model and Block Kit's interactive components covers everything from a simple slash command to a full workflow automation tool. This guide focuses on the JavaScript path (Node.js 20+), with notes on the Python equivalent.
What changed in 2026
- Bolt for JavaScript v4 ships as a first-class ESM package targeting Node.js 20+.
- Slack Platform (Deno Functions) is an alternative for simple automations, but Bolt on Node remains the most capable path for complex apps.
- Workflow Builder integration — apps can now expose "functions" that Slack Workflow Builder picks up without writing Workflow-Step APIs from scratch.
- Granular scopes matter. Slack's App Directory reviews scopes carefully; requesting
channels:history for a bot that doesn't need it will delay approval.
Project setup
mkdir my-slack-app && cd my-slack-app
npm init -y
npm install @slack/bolt
Create app.mjs:
import { App } from '@slack/bolt';
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode: true, // dev only
appToken: process.env.SLACK_APP_TOKEN, // for socket mode
});
app.command('/ping', async ({ command, ack, respond }) => {
await ack();
await respond(`Pong, <@${command.user_id}>!`);
});
await app.start();
console.log('Bolt app is running!');
For Socket Mode you need an App-Level Token (xapp-…) with connections:write scope, created in your app's "Basic Information" page.
Listening to events
app.event('message', async ({ event, say }) => {
if (event.subtype) return; // ignore edits, deletes, etc.
if (event.text?.toLowerCase().includes('hello')) {
await say({ text: `Hey there, <@${event.user}>!` });
}
});
Subscribe to events in your app manifest under Event Subscriptions. Add message.channels to listen in public channels (requires channels:history scope).
Slash commands + modals
app.command('/feedback', async ({ command, ack, client }) => {
await ack();
await client.views.open({
trigger_id: command.trigger_id,
view: {
type: 'modal',
callback_id: 'feedback_modal',
title: { type: 'plain_text', text: 'Give feedback' },
submit: { type: 'plain_text', text: 'Submit' },
blocks: [
{
type: 'input',
block_id: 'feedback_block',
element: { type: 'plain_text_input', action_id: 'feedback_input', multiline: true },
label: { type: 'plain_text', text: 'Your feedback' },
},
],
},
});
});
app.view('feedback_modal', async ({ view, ack, say }) => {
await ack();
const text = view.state.values.feedback_block.feedback_input.value;
console.log('Feedback received:', text);
});
Deployment comparison
| Mode |
Use case |
Requires public URL |
| Socket Mode |
Local dev, internal tools |
No |
| HTTP (Express) |
Production, public apps |
Yes |
| Vercel / AWS Lambda |
Serverless, low traffic |
Yes |
| Slack Platform (Deno) |
Simple automations |
No (Slack-hosted) |
For production on a VPS, switch socketMode: false and add an Express listener:
const app = new App({ token, signingSecret });
const { receiver } = app;
// app is an Express-compatible receiver by default
await app.start(process.env.PORT ?? 3000);
How to start
- Create an app at api.slack.com/apps → "From scratch."
- Under "OAuth & Permissions," add bot token scopes (
chat:write, commands, etc.).
- Install the app to your workspace; copy
SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET.
- Enable Socket Mode, create an App-Level Token, copy
SLACK_APP_TOKEN.
- Run locally, test slash commands in your workspace.
Common mistakes
Not calling ack() within 3 seconds. Slack retries if it doesn't get an acknowledgement. Always await ack() immediately; do async work after.
Registering slash commands with a leading / in the app config. The command name is ping, not /ping. Bolt adds the slash.
Forgetting message.subtype guards. Message events fire for edits and deletions too. Filter event.subtype to avoid infinite loops or double-processing.
Using say outside a message event. say posts to the originating channel. Outside message events, use client.chat.postMessage with an explicit channel ID.
What to skip
- DIY Slack event verification. Bolt handles signature verification; don't replicate it.
- Polling Slack channels. The Events API is push-based and far more efficient.
- Requesting all scopes "just in case" — it fails App Directory review and is a security risk.
FAQ
Can I use Bolt with TypeScript?
Yes — @slack/bolt ships full types. Use AppOptions, SlashCommand, and typed view/action payloads from the @slack/types package.
How do I handle multi-workspace (public Slack app) OAuth?
Use Bolt's built-in OAuthInstallationStore. Implement storeInstallation and fetchInstallation backed by your DB, then set installerOptions.
What is the difference between a bot token and a user token?
Bot tokens (xoxb-) act as the bot user. User tokens (xoxp-) act on behalf of a human user. Most apps only need a bot token.
Can Slack apps run serverless?
Yes, but each request is independent — don't rely on in-memory state. Use Redis or a DB for state. Bolt's HTTP mode works on Lambda and Vercel.
Where to go next