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.
Encra Team
Developer Relations
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
npm install @encra/react
Add your API key to .env.local:
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:
// 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
// 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
// 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:
- Key generation — Generates an X25519 keypair, stores the private key in IndexedDB (never leaves the device)
- Registration — Registers the public key with Encra's key server (
POST /v1/keys) - WebSocket connection — Opens an authenticated WebSocket for real-time delivery
- Key exchange — On first message to a recipient, fetches their public key and derives a shared secret
- Double Ratchet init — Initializes the ratchet state, persisted to IndexedDB
- Encryption — Each
sendMessagecall 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.
// 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:
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:
// 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:
{
"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 encryption →
useE2EFile— same pattern, wrapsFile/Blobobjects - Form fields →
useE2EForm— encrypts individual fields before sending to your API - Non-React →
EncraClientfrom@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.