Build a Real-Time Flutter Chat App for Free (2026 Tutorial)

Build a Real-Time Flutter Chat App for Free (2026 Tutorial)
Ryan Yang
Ryan Yang
Nexconn Infrastructure Engineer. Optimizes latency and scales microservices for hundreds of millions of concurrent users. Shares technical deep dives and backend lessons for zero-latency communication.

Flutter makes it possible to build one product experience for iOS and Android, but a reliable chat feature still needs more than a message list and a WebSocket connection. Mobile networks change, apps move between foreground and background, users sign in on multiple devices, and missed messages must be synchronized after reconnecting.

To support production-oriented integrations from day one, this tutorial uses the Nexconn Production-Ready Free Plan. This plan supports up to 10,000 MAU with full Pro-tier features—including multi-channel push notifications, 10GB storage/upload, and a 180-day history—with no credit card required. It is sized for real product launches, not just small-scale prototypes.

This tutorial uses the Nexconn Chat SDK for Flutter to build the core of a production-oriented chat integration: initialize the SDK, receive real-time and offline messages, securely connect a user, send a direct text message, expose events to Flutter widgets, and clean up the session correctly.

Getting Started & Architecture Overview

By the end, your Flutter app will have:

  • one centralized owner for the Nexconn engine lifecycle;
  • a secure token flow through your application backend;
  • connection status and incoming message streams;
  • direct text message sending with local-save and final-send callbacks;
  • explicit handler removal, disconnect, and engine destruction;
  • a foundation for group chat, media messages, unread state, push, and moderation.

The tutorial uses the lower-level Chat SDK. It does not generate a complete chat UI automatically. You can build your own screens on top of these APIs or use Nexconn Chat UI when you want prebuilt conversation and message components.

Prerequisites

The current Flutter SDK source requires:

  • Dart ^3.7.2;
  • Flutter >=3.29.2;
  • a Nexconn developer account;
  • a Development App Key;
  • an application backend that obtains a user token from the Nexconn Server API;
  • two test users if you want to verify a real message exchange.

Register in the Nexconn Console. The console creates a Development application and App Key. Development and Production environments have separate keys and isolated data.

Installation & Workflow Overview

1. Add the Flutter Package

Run this command in your terminal:

flutter pub add ai_nexconn_chat_plugin

2. High-Level Implementation Order

Follow this sequence to integrate the core messaging capabilities. Do not skip steps, especially handler registration.

NCEngine.initialize
  -> add connection and message handlers
  -> request a user token from your backend
  -> NCEngine.connect
  -> DirectChannel(...).sendMessage(...)
  -> remove handlers and disconnect when the session ends

Understand the Authentication Boundary

Nexconn uses two different credential types:

Credential Where it belongs Purpose
App Key Flutter client only Identifies the Nexconn application
App Secret Backend only Authorizes Nexconn Server API calls
User Token Returned by your backend to the signed-in client Connects and authenticates one Nexconn user

The production flow should look like this:

Flutter app -> GET /api/chat/token with app session
Backend -> verifies the current app user
Backend -> maps app user ID to a stable Nexconn user ID
Backend -> calls Nexconn user registration/token API using App Secret
Backend -> returns { userId, token }
Flutter app -> NCEngine.connect(ConnectParams(token: token))

Do not call the Nexconn Server API directly from Dart. A mobile binary can be inspected, so any App Secret included in it should be considered exposed.

📋 Before you continue

You’ll need a Nexconn App Key to initialize the SDK in the next steps.
If you haven’t already, create a free account and claim the Production-Ready Free Plan (10K MAU) now.

👉 Create your Nexconn App →

Build a Real-Time Flutter Chat App for Free

Initial Integration & Setup

Step 1: Add the Flutter Package

Run:

flutter pub add ai_nexconn_chat_plugin

Or add the dependency manually:

dependencies:
  flutter:
    sdk: flutter
  ai_nexconn_chat_plugin: ^26.2.8

Then fetch packages:

flutter pub get

Import the unified public API:

import 'package:ai_nexconn_chat_plugin/ai_nexconn_chat_plugin.dart';

Using flutter pub add is preferable for a new project because it resolves the latest compatible published version instead of freezing an article's version number indefinitely.

Step 2: SDK Initialization

Call NCEngine.initialize() before any other SDK API. Here's a simple app-level setup:

import 'package:ai_nexconn_chat_plugin/ai_nexconn_chat_plugin.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await NCEngine.initialize(
    InitParams(
      appKey: const String.fromEnvironment('NEXCONN_APP_KEY'),
      areaCode: AreaCode.sg,
      logLevel: kDebugMode ? LogLevel.debug : LogLevel.warn,
    ),
  );

  runApp(const MyApp());
}

InitParams also supports custom navigation, file, statistics, and log servers, push options, compression options, and reconnect device behavior. Most public-cloud integrations only need the App Key and the correct AreaCode.

Use build-time configuration for Development and Production App Keys:

flutter run --dart-define=NEXCONN_APP_KEY=your-development-app-key

An App Key is not a signing secret, but environment configuration still helps prevent Development and Production data from being mixed.

Step 3: Registering Event Handlers

Register message and connection handlers before calling connect. The server can begin delivering connection state and offline messages as soon as the session is established.

NCEngine.addConnectionStatusHandler('app-connection', (event) {
  print('Connection status: ${event.status}');
});

NCEngine.addMessageHandler(
  'app-messages',
  MessageHandler(
    onMessageReceived: (event) {
      final message = event.message;
      print('Received message: ${message.messageId}');
      print('Offline message: ${event.offline}');
      print('Messages left in package: ${event.left}');
    },
    onOfflineMessageSyncCompleted: (event) {
      print('Offline message synchronization completed');
    },
  ),
);

The message event includes:

  • message: the received Nexconn message;
  • offline: whether it is a missed message;
  • left: the number of messages remaining in the current delivery package;
  • hasPackage: whether additional packages remain on the server.

Do not use the same handler ID for unrelated owners. A unique ID lets a page, feature, or app-level service remove only its own callbacks.

Step 4:  Connecting the User with a Token

Assume your authenticated backend endpoint returns:

{
  "userId": "user-123",
  "token": "user-specific-nexconn-token"
}

Connect once for the authenticated app lifecycle:

await NCEngine.connect(
  ConnectParams(token: token),
  (userId, error) {
    final success = userId != null && (error == null || error.isSuccess);

    if (success) {
      print('Connected as $userId');
    } else {
      print('Connection failed: $error');
    }
  },
);

The Flutter wrapper can report a successful connection as NCError(code: 0). For that reason, checking only error == null is too strict. Use error.isSuccess or accept both null and code 0 as success.

The SDK handles automatic reconnection. Observe connection status to update banners or disable sending when necessary, but do not call connect again for every temporary network interruption.

Step 5: Send a Direct Text Message

A one-to-one conversation is represented by DirectChannel, whose channel ID is the other user's ID.

final channel = DirectChannel('recipient-user-id');

await channel.sendMessage(
  SendMessageParams(
    messageParams: TextMessageParams(
      text: 'Hello from Nexconn Flutter!',
    ),
  ),
  callback: SendMessageCallback(
    onMessageSaved: (message) {
      // The message is stored locally. Insert or update it in the UI.
      print('Saved locally: ${message?.messageId}');
    },
    onMessageSent: (code, message) {
      if (code == 0) {
        print('Sent: ${message?.messageId}');
      } else {
        print('Send failed with code $code');
      }
    },
  ),
);

The two callbacks support an optimistic UI:

  1. onMessageSaved gives you the locally persisted message.
  2. Render it immediately with a sending state.
  3. onMessageSent changes the bubble to sent or failed.
  4. Keep the message ID stable so the UI updates the existing item instead of inserting a duplicate.

Step 6: Handling Various Message Types

The SDK exports typed message classes. A basic renderer can branch on the received message type:

String messagePreview(Message message) {
  if (message is TextMessage) {
    return message.text ?? '';
  }
  if (message is ImageMessage) {
    return '[Image]';
  }
  if (message is FileMessage) {
    return '[File]';
  }
  if (message is HDVoiceMessage) {
    return '[Voice]';
  }
  if (message is ShortVideoMessage) {
    return '[Video]';
  }
  if (message is CustomMessage) {
    return '[Custom message]';
  }
  return '[Unsupported message]';
}

Nexconn also exposes GIF, location, reference, combined, command, stream, custom media, group notification, and other message types. Add only the types your product can render, and provide a safe fallback for messages introduced by a newer client.

Step 7: Creating a Reusable Service

Initializing and registering listeners from individual widgets creates duplicate connections and lifecycle bugs. A better Flutter pattern is one app-level service that owns the engine and exposes streams to your state-management layer.

import 'dart:async';

import 'package:ai_nexconn_chat_plugin/ai_nexconn_chat_plugin.dart';

class NexconnChatService {
  static const _connectionHandlerId = 'nexconn-chat-service-connection';
  static const _messageHandlerId = 'nexconn-chat-service-messages';

  final _messages = StreamController<Message>.broadcast();
  final _connectionStatuses = StreamController<ConnectionStatus>.broadcast();

  bool _initialized = false;
  bool _handlersRegistered = false;

  Stream<Message> get messages => _messages.stream;
  Stream<ConnectionStatus> get connectionStatuses =>
      _connectionStatuses.stream;

  Future<void> initialize({
    required String appKey,
    AreaCode areaCode = AreaCode.sg,
  }) async {
    if (_initialized) return;

    await NCEngine.initialize(
      InitParams(
        appKey: appKey,
        areaCode: areaCode,
      ),
    );

    _registerHandlers();
    _initialized = true;
  }

  void _registerHandlers() {
    if (_handlersRegistered) return;

    NCEngine.addConnectionStatusHandler(_connectionHandlerId, (event) {
      _connectionStatuses.add(event.status);
    });

    NCEngine.addMessageHandler(
      _messageHandlerId,
      MessageHandler(
        onMessageReceived: (event) {
          _messages.add(event.message);
        },
      ),
    );

    _handlersRegistered = true;
  }

  Future<String> connect(String token) {
    final completer = Completer<String>();

    void finish(String? userId, NCError? error) {
      if (completer.isCompleted) return;

      if (userId != null && (error == null || error.isSuccess)) {
        completer.complete(userId);
      } else {
        completer.completeError(
          error ?? StateError('Nexconn connected without a user ID'),
        );
      }
    }

    NCEngine.connect(ConnectParams(token: token), finish).catchError((error) {
      if (!completer.isCompleted) completer.completeError(error);
      return -1;
    });

    return completer.future;
  }

  Future<Message?> sendText({
    required String targetUserId,
    required String text,
  }) async {
    final normalized = text.trim();
    if (normalized.isEmpty) {
      throw ArgumentError.value(text, 'text', 'Message cannot be empty');
    }

    final completer = Completer<Message?>();
    final channel = DirectChannel(targetUserId);

    final requestCode = await channel.sendMessage(
      SendMessageParams(
        messageParams: TextMessageParams(text: normalized),
      ),
      callback: SendMessageCallback(
        onMessageSent: (code, message) {
          if (completer.isCompleted) return;
          if (code == 0) {
            completer.complete(message);
          } else {
            completer.completeError(
              NCError(code: code, message: 'Failed to send message'),
            );
          }
        },
      ),
    );

    if (requestCode != 0 && !completer.isCompleted) {
      completer.completeError(
        NCError(code: requestCode, message: 'Send request was rejected'),
      );
    }

    return completer.future;
  }

  Future<void> disconnect() async {
    if (!_initialized) return;
    await NCEngine.disconnect();
  }

  Future<void> dispose() async {
    if (_handlersRegistered) {
      NCEngine.removeConnectionStatusHandler(_connectionHandlerId);
      NCEngine.removeMessageHandler(_messageHandlerId);
      _handlersRegistered = false;
    }

    if (_initialized) {
      await NCEngine.disconnect();
      await NCEngine.destroy();
      _initialized = false;
    }

    await _messages.close();
    await _connectionStatuses.close();
  }
}

Provide this service through Riverpod, Provider, get_it, Bloc, or your existing dependency injection system. The important decision is ownership: only one object should initialize and destroy the global engine.

Step 8: Connecting the Service to Your UI

A chat screen normally combines three sources of state:

  • initial paginated history from the channel query APIs;
  • real-time messages from NexconnChatService.messages;
  • local outgoing messages and their sending/failed state.

Keep SDK Message objects in a repository or map them into your own view model:

class ChatMessageViewData {
  final String id;
  final String text;
  final bool isMine;
  final bool isSending;
  final bool hasFailed;

  const ChatMessageViewData({
    required this.id,
    required this.text,
    required this.isMine,
    required this.isSending,
    required this.hasFailed,
  });
}

This separation makes it easier to:

  • preserve scroll position while older messages load;
  • merge local and remote updates by message ID;
  • retry failed sends;
  • render unsupported custom messages safely;
  • test the widget layer without a live SDK connection.

For a first UI, use a reverse ListView, a composer with a send command, explicit loading/error states, and a connection banner. Avoid placing SDK initialization inside build() or page-level initState() if navigating between pages can create more than one owner.

Beyond Text Chat: Groups, Media, & Push

Group Chat and Other Channel Types

Once direct messaging is stable, the same send pattern works with other channel objects:

final group = GroupChannel('group-id');
final open = OpenChannel('open-channel-id');
final community = CommunityChannel('community-id');

The relevant product rules are different:

  • group channels need membership, roles, invites, kicks, mutes, and profile updates;
  • open channels need enter/leave state, high-volume behavior, moderation, and message priority;
  • community channels need subchannel navigation, permissions, and large-scale member design.

Do not treat these as a UI-only switch. Define authorization and moderation behavior on your backend before exposing the channel to users.

Rich Media Messages

The SDK separates regular messages from media upload flows. For images, files, voice, video, GIF, and custom media messages, use sendMediaMessage with the corresponding message params and handle:

  • local save/attachment;
  • upload progress;
  • final send result;
  • cancellation;
  • local path and remote URL state;
  • file size, compression, retry, and permission failures.

Test media on real iOS and Android devices. Simulators are not enough for camera, microphone, photo-library permissions, background behavior, and push notifications.

Planning Offline Push Notifications

Real-time delivery works while the client is connected. Mobile engagement also requires APNs and FCM configuration, device token lifecycle handling, notification payloads, deep links, muted-channel behavior, and badge reconciliation.

Push is a native platform integration even when the app is written in Flutter. Complete the Nexconn Android and iOS push setup, then test:

  • app in foreground;
  • app in background;
  • app terminated;
  • token refresh;
  • user logout and account switching;
  • tapping a notification for an existing or unavailable channel.

Common Integration Mistakes

Initializing from more than one widget

NCEngine is global. Initialize it in one app-level owner, not every screen.

Connecting before registering handlers

Register handlers first so initial connection and offline synchronization events are observable.

Treating error != null as connection failure

The current wrapper can return NCError(code: 0) on success. Check isSuccess.

Calling connect after every reconnect event

The SDK reconnects automatically. Repeated calls can create race conditions and confusing UI state.

Forgetting to remove handlers

Handler registries use string IDs. Remove the exact ID when the owner is disposed, or callbacks can be delivered to stale feature state.

Rendering a second outgoing message after the callback

Use the locally saved message ID to update the optimistic bubble. Do not insert a new list item for each callback stage.

Shipping test tokens

Tokens belong to individual users and should come from your authenticated backend. Do not commit sample tokens or App Secrets.

Production Checklist

  • Flutter and Dart versions satisfy the package requirements.
  • Development and Production App Keys are selected by environment.
  • App Secret and Server API calls stay on the backend.
  • Token endpoint verifies the current app session and returns a stable user mapping.
  • One app-level service owns initialization, handlers, connection, and destruction.
  • Handlers are registered before connecting and removed during disposal.
  • Connection success accepts NCError(code: 0) in the Flutter wrapper.
  • The UI supports loading, empty, reconnecting, token-expired, sending, sent, and failed states.
  • History and live events are merged by stable message ID.
  • Media permissions, upload progress, cancellation, and retry are tested on real devices.
  • APNs/FCM push and notification deep links are verified.
  • Offline, app restart, account switching, weak network, and multi-device behavior are tested.
  • Group/open/community permissions and moderation are enforced outside the UI.

Start the Flutter Integration

Create a free Nexconn application, add the Flutter package, implement one authenticated token endpoint, and test the first direct message between two users.

Further Reading

Contact us
Contact us
We'd love to discuss how Nexconn's real-time communication solutions can support your business. Request a demo, explore pricing, or get tailored onboarding guidance.

Related Articles

Build Real-Time Chat Across Web, iOS, Android, and Flutter with Nexconn

Build Real-Time Chat Across Web, iOS, Android, and Flutter with Nexconn

Home > Blog > Build Real-Time Chat Across Platforms Deploy production-grade in-app chat across Web, iOS, Android, and Flutter at $0 with our Free Multi-Platform Chat SDK (10K MAU). Adding a message input and a WebSocket is easy. Building chat that survives network changes, app restarts, offline periods, multiple devices, media uploads, unread state, group growth, and production traffic is a different problem. This guide shows the shortest practical path to real-time in-app chat wit

Scaling Unlimited Live Chat: Architecture, Consistent Hashing, and Notification-Pull

Scaling Unlimited Live Chat: Architecture, Consistent Hashing, and Notification-Pull

Home > Blog > Scaling Unlimited Live Chat Live streaming has a concurrency problem that most infrastructure wasn't designed to solve. When a major broadcast goes live — a national event, a product launch, a celebrity stream — viewer counts can spike from thousands to millions within minutes. Every one of those viewers expects to see the chat moving in real time. They want to send messages, receive reactions, and feel like they're part of something happening right now. Any lag, any d

The Push Delivery Rate Trap: How Global Device Fragmentation Kills User Retention

The Push Delivery Rate Trap: How Global Device Fragmentation Kills User Retention

Home > Blog > Push Notification Delivery Guide Looking to maximize message retention without building custom push infrastructure? Explore our Production-Ready Free Chat SDK (10K MAU) — turnkey multi-channel push and full Pro features at zero cost. While industry benchmarks for push opt-in rates hover around 60%, the actual delivery rate in complex, multi-manufacturer markets like Southeast Asia often tells a very different story—frequently dropping below 60% when FCM is deprioritize