Your chat, on your own server.

Swift Chat draws the conversation. Swift Chat Server is everything behind it: people, direct and group threads, messages that arrive in order, “Delivered” and “Read”, typing dots, tapbacks, photos and files, push. It runs as one Vercel project with one Postgres database, in your account. No per-user bill, no vendor between you and your users’ messages.

MIT. Node 22 on Vercel, any Postgres, Vercel Blob for attachments. The Swift client needs iOS 18 and Swift Chat 1.0.8.

One view, wired.

Point a client at your deployment, sign the person in, and a thread is one line. Bubbles, tails, typing dots, receipts, photos, tapbacks, older pages on scroll, and sends that retry, all talking to your server.

https://github.com/unionst/swift-chat-server.git
Product SwiftChatClient, beside SwiftChat from the main package.
import SwiftChat
import SwiftChatClient

@main
struct MyApp: App {
    @State private var client = ChatClient(baseURL: URL(string: "https://your-deployment.vercel.app")!)

    var body: some Scene {
        WindowGroup {
            RootView(client: client)
        }
    }
}

struct ThreadScreen: View {
    let client: ChatClient
    let conversationID: String

    var body: some View {
        ConversationView(client: client, conversationID: conversationID)
    }
}

Every Swift Chat modifier still applies to ConversationView, because they travel through the environment. When you want your own transcript, the client hands you the same pieces: client.transcriptRow(for:), client.typingRoles(in:), client.contextMenuItems(for:in:), client.send(text:media:in:).

What’s inside.

The parts of a messenger that take a year to get right, built once and kept correct.

In order, never lost

Each conversation owns a sequence number bumped inside the write transaction. A reader asking for “after N” cannot miss a row that committed late.

Live

One Server-Sent Events stream per person, woken by Postgres LISTEN/NOTIFY, resumed from a cursor. A dropped connection costs latency, not messages.

Receipts

Delivered when the message reaches a device, by stream or by push. Read when the person opens the thread. In a group, “Read” means everyone.

People and groups

Users carry your own ids. Direct threads are found again by the pair, so there is only ever one. Groups add, leave and rename.

Typing and tapbacks

Typing dots ride inside the notification and are never stored. Tapbacks land per message part, so a photo with a caption has two.

Attachments and push

Photos and files on Vercel Blob, resized on the phone. APNs with unread badges and thread grouping, from your own key.

Optimistic sends

The bubble appears at once and becomes the stored row without a flicker. A resend carries the same id and is idempotent on the server.

An admin API

Your backend creates people, mints tokens, opens conversations and speaks into them, as a person or as the system.

Yours

MIT. The database is yours, the Blob store is yours, the deployment is yours. There is nothing to migrate off later.

Deploy in five minutes.

The button clones the repo into your Vercel account, attaches a Neon Postgres database and a Blob store, and asks for one value. The schema is applied on every build.

  1. Deploy. Click the button, or clone the repo and run vercel --prod with DATABASE_URL, DATABASE_URL_UNPOOLED and SWIFT_CHAT_SECRET set.
  2. Sign people in. Your backend calls POST /admin/users with your own user id and gets a token back. Hand it to the app and call client.signIn(token:). No backend yet? Set ALLOW_ANONYMOUS_USERS=1 and call client.signInAnonymously(name:).
  3. Add push. Set APNS_TEAM_ID, APNS_KEY_ID, APNS_KEY and APNS_BUNDLE_ID, then forward the device token to client.registerDevice(token:).
curl -X POST https://your-deployment.vercel.app/admin/users \
  -H "Authorization: Bearer $SWIFT_CHAT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"external_id": "user_42", "name": "Alice"}'

{"user": {"id": "usr_…", "external_id": "user_42", "name": "Alice"}, "token": "…"}

Calling it again with the same external_id returns the same person, so a sign-in handler can call it every time. The full reference, every route and event with its fields, is served from your deployment at /llms.txt, so a coding agent pointed at your server can read it too.

Why not a hosted service.

Hosted chat charges by the person, before your app earns anything, and the moment you outgrow the free tier your users’ messages live in someone else’s database. This is the other way round: the server is small, the database is yours, and the code is open. Deploy it next to the rest of your app and forget about it.