Skip to content
  • Integrations
  • Third-party tools

Build a real-time message board with Kinde and Convex

In this tutorial you’ll build a small message board where only signed-in users can post, and everyone sees new messages as they arrive—no page reload. You’ll define a messages table in Convex, add list and send mutations plus a query, and wire up a React UI that uses the user data you synced in the Kinde + Convex integration guide.

Create messages schema and function

Link to this section
  1. Add the following messages schema to your Convex schema:

    convex/schema.ts
    export default defineSchema({
    // ... existing schema ...
    messages: defineTable({
    userId: v.string(),
    authorName: v.optional(v.string()),
    message: v.string(),
    }).index("by_user_id", ["userId"]),
    });
  2. Create the messages file and add the contents:

    Terminal window
    touch convex/messages.ts
    convex/messages.ts
    import { v } from "convex/values";
    import { mutation, query } from "./_generated/server";
    export const list = query({
    args: {},
    handler: async (ctx) => {
    return await ctx.db
    .query("messages")
    .order("desc")
    .take(100);
    },
    });
    export const sendMessage = mutation({
    args: {
    message: v.string(),
    },
    handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
    throw new Error("Must be authenticated to send a message");
    }
    const user = await ctx.db
    .query("users")
    .withIndex("by_kinde_id", (q) => q.eq("kindeId", identity.subject))
    .unique();
    if (!user) {
    throw new Error("User not found");
    }
    await ctx.db.insert("messages", {
    userId: user.kindeId,
    authorName: user.givenName ?? "Someone",
    message: args.message,
    });
    },
    });

    The above code creates two functions:

    • list: A query function that returns the last 100 messages from the database.
    • sendMessage: A mutation function that inserts a new message into the database.

Create a MessageBoard component

Link to this section
  1. Create a new file and add the following code:

    Terminal window
    touch src/MessageBoard.tsx
    src/MessageBoard.tsx
    import { useMutation, useQuery } from "convex/react";
    import { useState } from "react";
    import { api } from "../convex/_generated/api";
    export default function MessageBoard() {
    const messages = useQuery(api.messages.list);
    const sendMessage = useMutation(api.messages.sendMessage);
    const [message, setMessage] = useState("");
    async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const trimmed = message.trim();
    if (!trimmed) return;
    await sendMessage({ message: trimmed });
    setMessage("");
    }
    return (
    <section className="message-board">
    <h2 className="message-board-title">Message Board</h2>
    <ul className="message-list">
    {messages === undefined ? (
    <li className="message-item">Loading messages…</li>
    ) : messages.length === 0 ? (
    <li className="message-item">No messages yet. Send one below.</li>
    ) : (
    messages.map((m) => (
    <li key={m._id} className="message-item">
    <span className="message-author">{m.authorName ?? "Someone"}:</span>
    {m.message}
    </li>
    ))
    )}
    </ul>
    <form onSubmit={handleSubmit} className="message-form">
    <input
    type="text"
    className="message-input"
    value={message}
    onChange={(e) => setMessage(e.target.value)}
    placeholder="Type a message..."
    aria-label="Message"
    />
    <button type="submit" className="btn btn-primary" disabled={!message.trim()}>
    Send
    </button>
    </form>
    </section>
    );
    }
  2. Update your App.tsx file to include the MessageBoard component inside the <Authenticated> block:

    src/App.tsx
    /* ... existing imports ... */
    import MessageBoard from "./MessageBoard";
    <Authenticated>
    {/* ... existing code ... */}
    <MessageBoard />
    </Authenticated>

Add optional styles

Link to this section
src/App.css
:root {
--kinde-primary: #111111;
--kinde-primary-hover: #333333;
--kinde-base: #1f2937;
--kinde-base-muted: #6b7280;
--kinde-bg: #ffffff;
--kinde-bg-subtle: #f9fafb;
--kinde-border: #e5e7eb;
--kinde-radius: 8px;
--kinde-font: system-ui, -apple-system, sans-serif;
}
/* Layout */
#root {
max-width: 640px;
margin: 0 auto;
padding: 2rem;
font-family: var(--kinde-font);
color: var(--kinde-base);
background: var(--kinde-bg);
}
.app {
min-height: 100vh;
}
.app-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--kinde-border);
font-size: 0.9375rem;
color: var(--kinde-base-muted);
}
.app-header strong {
color: var(--kinde-base);
}
.auth-links a {
color: var(--kinde-primary);
text-decoration: none;
font-weight: 500;
}
.auth-links a:hover {
color: var(--kinde-primary-hover);
text-decoration: underline;
}
.auth-links a + a {
margin-left: 1rem;
}
.loading {
color: var(--kinde-base-muted);
padding: 2rem;
text-align: center;
}
/* Message board */
.message-board {
background: var(--kinde-bg-subtle);
border: 1px solid var(--kinde-border);
border-radius: var(--kinde-radius);
padding: 1.25rem;
}
.message-board-title {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: var(--kinde-base);
}
.message-list {
list-style: none;
padding: 0;
margin: 0 0 1rem;
max-height: 40vh;
overflow-y: auto;
}
.message-item {
padding: 0.5rem 0.75rem;
margin-bottom: 0.25rem;
background: var(--kinde-bg);
border-radius: 6px;
font-size: 0.875rem;
border: 1px solid var(--kinde-border);
}
.message-author {
color: var(--kinde-primary);
font-weight: 600;
margin-right: 0.35rem;
}
.message-form {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.message-input {
flex: 1;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-family: inherit;
border: 1px solid var(--kinde-border);
border-radius: 6px;
background: var(--kinde-bg);
color: var(--kinde-base);
}
.message-input::placeholder {
color: var(--kinde-base-muted);
}
.message-input:focus {
outline: none;
border-color: var(--kinde-primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
/* Buttons – Kinde primary style */
.btn {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
border-radius: 6px;
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.btn-primary {
background: var(--kinde-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--kinde-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}

Run and test the application

Link to this section
  1. Run the application using the following terminal command:

    Terminal window
    npm run dev
  2. Go to http://localhost:5173 and sign in or create a new account.

    You should see the message board with the ability to send messages.

    real time application with convex