All posts
tutorialnextjsreactquickstart

Add End-to-End Encrypted Chat to Your Next.js App in 10 Minutes

A practical walkthrough: install the SDK, wire up the hook, and have two users exchanging Signal-level encrypted messages before your coffee gets cold.

E

Encra Team

Developer Relations

3 min read869 words

This is a no-fluff tutorial. By the end you'll have real E2E encrypted chat running in a Next.js app. Messages are encrypted on the sender's device, relayed as ciphertext, and decrypted only on the recipient's device — the server never sees plaintext.

What you're building

Two components: a chat window and a message input. Users authenticate with your existing auth (pass in a userId), and Encra handles everything else — key exchange, the Double Ratchet, WebSocket relay, IndexedDB persistence.

Prerequisites

  • Next.js 13+ with App Router
  • An Encra API key (get one free)
  • Node.js 18+

Step 1: Install

bash
npm install @encra/react

Add your API key to .env.local:

bash
NEXT_PUBLIC_ENCRA_API_KEY=your_api_key_here

Step 2: Wrap your app with the provider

In your root layout or the layout wrapping the chat section:

tsx
// app/layout.tsx
import { E2EChatProvider } from "@encra/react"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <E2EChatProvider
          apiKey={process.env.NEXT_PUBLIC_ENCRA_API_KEY!}
          userId={currentUser.id} // from your auth session
        >
          {children}
        </E2EChatProvider>
      </body>
    </html>
  )
}

Or skip the provider and pass config directly to the hook — both work.

Step 3: Build the chat component

tsx
// components/encrypted-chat.tsx
"use client"

import { useE2EChat } from "@encra/react"

interface Props {
  recipientId: string
}

export function EncryptedChat({ recipientId }: Props) {
  const { messages, isReady, isConnecting, sendMessage, error } = useE2EChat({
    apiKey: process.env.NEXT_PUBLIC_ENCRA_API_KEY!,
    userId: currentUser.id,
  })

  const [text, setText] = useState("")

  const handleSend = async () => {
    if (!text.trim() || !isReady) return
    await sendMessage(recipientId, text)
    setText("")
  }

  if (isConnecting) return <p>Establishing secure channel…</p>
  if (error) return <p>Error: {error.message}</p>

  return (
    <div className="flex flex-col gap-4">
      {/* Message list */}
      <div className="flex-1 overflow-y-auto space-y-2">
        {messages.map((msg) => (
          <div
            key={msg.id}
            className={msg.from === currentUser.id ? "text-right" : "text-left"}
          >
            <span className="rounded-lg bg-muted px-3 py-1.5 text-sm">
              {msg.text}
            </span>
          </div>
        ))}
      </div>

      {/* Input */}
      <div className="flex gap-2">
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleSend()}
          placeholder="Message…"
          disabled={!isReady}
          className="flex-1 rounded-lg border px-3 py-2 text-sm"
        />
        <button
          onClick={handleSend}
          disabled={!isReady || !text.trim()}
          className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground"
        >
          Send
        </button>
      </div>
    </div>
  )
}

Step 4: Use it

tsx
// app/chat/page.tsx
import { EncryptedChat } from "@/components/encrypted-chat"

export default function ChatPage() {
  return (
    <main className="mx-auto max-w-xl p-6">
      <h1 className="mb-4 text-xl font-bold">Encrypted Chat</h1>
      <EncryptedChat recipientId="bob-user-id" />
    </main>
  )
}

That's the core. Let's look at what's happening under the hood and how to handle real-world requirements.

What useE2EChat does for you

When the component mounts:

  1. Key generation — Generates an X25519 keypair, stores the private key in IndexedDB (never leaves the device)
  2. Registration — Registers the public key with Encra's key server (POST /v1/keys)
  3. WebSocket connection — Opens an authenticated WebSocket for real-time delivery
  4. Key exchange — On first message to a recipient, fetches their public key and derives a shared secret
  5. Double Ratchet init — Initializes the ratchet state, persisted to IndexedDB
  6. Encryption — Each sendMessage call derives a fresh per-message key, encrypts, sends ciphertext

Messages arrive as ciphertext over the WebSocket. The hook decrypts them locally using the ratchet state and appends to messages.

Multi-device support

If your users might have multiple devices (desktop + mobile), Encra handles this automatically. When you call sendMessage, it fetches all registered device public keys for the recipient and creates one encrypted copy per device. Each device decrypts independently with its own private key.

tsx
// No changes needed — multi-device just works
await sendMessage(recipientId, "Hello from any device")

Handling the ready state properly

The isReady flag is true once the WebSocket is open and key registration is confirmed. Never call sendMessage before this:

tsx
const handleSend = async () => {
  if (!isReady) return  // ← important
  await sendMessage(recipientId, text)
}

If you try to send before ready, you'll get a "WebSocket not open" error. The isConnecting flag tells you when it's still establishing the connection.

Reconnection

The hook includes automatic exponential backoff reconnection. If the WebSocket drops (tab goes background, network blip, server restart), it reconnects automatically. Offline messages are queued server-side and flushed on reconnect.

Adding to an existing auth system

userId is just a string — it should be whatever stable identifier your users already have. Pass your auth session's user ID:

tsx
// With Next-Auth
import { useSession } from "next-auth/react"
const { data: session } = useSession()

const chat = useE2EChat({
  apiKey: process.env.NEXT_PUBLIC_ENCRA_API_KEY!,
  userId: session?.user?.id ?? "",
})

// With Better-Auth
import { useSession } from "@/lib/auth-client"
const { data: session } = useSession()

What the server sees

Zero plaintext. The Encra relay only sees:

json
{
  "to": "bob",
  "deviceId": "device-abc123",
  "ciphertext": "base64encodedblob...",
  "header": { "ephemeralKey": "...", "prevChainLength": 0, "messageIndex": 1 }
}

The header is metadata needed for the Double Ratchet. The ciphertext is XSalsa20-Poly1305 encrypted. Without the private key — which never leaves the device — it's computational gibberish.


Next steps

  • File encryptionuseE2EFile — same pattern, wraps File / Blob objects
  • Form fieldsuseE2EForm — encrypts individual fields before sending to your API
  • Non-ReactEncraClient from @encra/client — same protocol, event-emitter API for Vue, Svelte, Node

Full API reference at encra.dev/docs. Get a free API key at encra.dev/signup.