Build a Real-Time Full-Stack App with Convex and Next.js 15

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Convex is a reactive backend-as-a-service that replaces your database, server functions, and real-time infrastructure with a single TypeScript-native platform. Unlike traditional backends where you wire up REST endpoints, ORM layers, and WebSocket servers separately, Convex gives you a reactive database that automatically pushes updates to every connected client the moment data changes.

In this tutorial, you will build a real-time collaborative note-taking app — think a simplified Notion where multiple users can create, edit, and organize notes that instantly sync across all browsers. Along the way, you will master Convex schemas, queries, mutations, real-time subscriptions, file uploads, and authentication with Clerk.

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed
  • A free Convex account — sign up at convex.dev
  • Basic knowledge of React and TypeScript
  • Familiarity with Next.js App Router (layouts, server components, client components)
  • A code editor (VS Code recommended)

What You Will Build

A collaborative notes app with these features:

  • Real-time note syncing — changes appear instantly on all connected clients
  • Rich note management — create, edit, delete, pin, and search notes
  • File attachments — upload images and documents to notes via Convex file storage
  • User authentication — sign in with Clerk, scoped data per user
  • End-to-end type safety — from database schema to React components, everything is typed

Step 1: Create the Next.js Project

Start by scaffolding a new Next.js 15 project with TypeScript and Tailwind CSS:

npx create-next-app@latest convex-notes --typescript --tailwind --eslint --app --src-dir --use-npm
cd convex-notes

When prompted, accept the default options. This creates a Next.js project with the App Router and src/ directory structure.

Step 2: Install Convex

Install the Convex client library and initialize your project:

npm install convex
npx convex init

The convex init command creates a convex/ directory in your project root. This is where all your backend code lives — schema definitions, queries, mutations, and actions. Convex also creates a .env.local file with your NEXT_PUBLIC_CONVEX_URL automatically.

Your project structure now looks like this:

convex-notes/
├── convex/           # Backend code (runs on Convex servers)
│   ├── _generated/   # Auto-generated types and API references
│   └── tsconfig.json
├── src/
│   └── app/          # Next.js App Router pages
├── .env.local        # NEXT_PUBLIC_CONVEX_URL
└── package.json

Step 3: Define the Database Schema

Convex uses a TypeScript-first schema system. Every table and field is defined with validators that provide both runtime validation and compile-time type inference.

Create the schema file at convex/schema.ts:

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
 
export default defineSchema({
  notes: defineTable({
    userId: v.string(),
    title: v.string(),
    content: v.string(),
    isPinned: v.boolean(),
    fileId: v.optional(v.id("_file_storage")),
    fileName: v.optional(v.string()),
  })
    .index("by_user", ["userId"])
    .index("by_user_pinned", ["userId", "isPinned"])
    .searchIndex("search_notes", {
      searchField: "content",
      filterFields: ["userId"],
    }),
});

Key concepts here:

  • v validatorsv.string(), v.boolean(), v.optional(), and v.id() define field types with runtime validation
  • Indexesby_user enables efficient queries filtered by userId. Convex requires you to declare indexes upfront
  • Search indexsearch_notes enables full-text search on the content field, filterable by userId
  • _file_storage — a built-in Convex table for uploaded files

Push the schema to your Convex deployment:

npx convex dev

Keep this running in a terminal — it watches for changes and automatically deploys your backend code. This is one of Convex's best features: hot-reload for your backend.

Step 4: Write Query Functions

Convex queries are TypeScript functions that run on the server and automatically re-execute whenever the underlying data changes. Connected clients receive updates in real time without any polling or WebSocket setup.

Create convex/notes.ts:

import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
 
// List all notes for the current user
export const list = query({
  args: { userId: v.string() },
  returns: v.array(
    v.object({
      _id: v.id("notes"),
      _creationTime: v.number(),
      userId: v.string(),
      title: v.string(),
      content: v.string(),
      isPinned: v.boolean(),
      fileId: v.optional(v.id("_file_storage")),
      fileName: v.optional(v.string()),
    })
  ),
  handler: async (ctx, args) => {
    const notes = await ctx.db
      .query("notes")
      .withIndex("by_user", (q) => q.eq("userId", args.userId))
      .order("desc")
      .collect();
 
    // Sort pinned notes to the top
    return notes.sort((a, b) => {
      if (a.isPinned && !b.isPinned) return -1;
      if (!a.isPinned && b.isPinned) return 1;
      return 0;
    });
  },
});
 
// Search notes by content
export const search = query({
  args: {
    userId: v.string(),
    searchTerm: v.string(),
  },
  handler: async (ctx, args) => {
    const results = await ctx.db
      .query("notes")
      .withSearchIndex("search_notes", (q) =>
        q.search("content", args.searchTerm).eq("userId", args.userId)
      )
      .collect();
 
    return results;
  },
});

Notice how queries declare their args with validators. Convex validates these at runtime and infers TypeScript types at compile time. If you pass the wrong type from the client, you get both a TypeScript error and a runtime rejection.

Step 5: Write Mutation Functions

Mutations are how you modify data in Convex. Like queries, they are validated, typed, and transactional — every mutation runs in a serializable transaction, so you never have race conditions.

Add these mutations to convex/notes.ts:

// Create a new note
export const create = mutation({
  args: {
    userId: v.string(),
    title: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    const noteId = await ctx.db.insert("notes", {
      userId: args.userId,
      title: args.title,
      content: args.content,
      isPinned: false,
    });
    return noteId;
  },
});
 
// Update an existing note
export const update = mutation({
  args: {
    noteId: v.id("notes"),
    title: v.optional(v.string()),
    content: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const { noteId, ...updates } = args;
    // Remove undefined fields
    const cleanUpdates: Record<string, string> = {};
    if (updates.title !== undefined) cleanUpdates.title = updates.title;
    if (updates.content !== undefined) cleanUpdates.content = updates.content;
 
    await ctx.db.patch(noteId, cleanUpdates);
  },
});
 
// Toggle pin status
export const togglePin = mutation({
  args: { noteId: v.id("notes") },
  handler: async (ctx, args) => {
    const note = await ctx.db.get(args.noteId);
    if (!note) throw new Error("Note not found");
    await ctx.db.patch(args.noteId, { isPinned: !note.isPinned });
  },
});
 
// Delete a note
export const remove = mutation({
  args: { noteId: v.id("notes") },
  handler: async (ctx, args) => {
    const note = await ctx.db.get(args.noteId);
    if (!note) throw new Error("Note not found");
 
    // Delete associated file if exists
    if (note.fileId) {
      await ctx.storage.delete(note.fileId);
    }
    await ctx.db.delete(args.noteId);
  },
});

A few things to notice:

  • ctx.db.insert() — creates a new document and returns its ID
  • ctx.db.patch() — partially updates a document (only the specified fields)
  • ctx.db.delete() — removes a document
  • ctx.storage.delete() — removes a file from Convex storage
  • Every mutation is atomic — if any step fails, the entire transaction rolls back

Step 6: Set Up the Convex Provider

To use Convex in your React components, you need to wrap your app with the ConvexProvider. Create a client provider component.

Create src/components/ConvexClientProvider.tsx:

"use client";
 
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
 
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
 
export default function ConvexClientProvider({
  children,
}: {
  children: ReactNode;
}) {
  return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}

Then wrap your app in src/app/layout.tsx:

import type { Metadata } from "next";
import "./globals.css";
import ConvexClientProvider from "@/components/ConvexClientProvider";
 
export const metadata: Metadata = {
  title: "Convex Notes",
  description: "Real-time collaborative notes powered by Convex",
};
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <ConvexClientProvider>{children}</ConvexClientProvider>
      </body>
    </html>
  );
}

Step 7: Build the Notes UI

Now for the exciting part — building the React components that consume your Convex backend. The magic of Convex is that useQuery automatically subscribes to real-time updates. When any client creates, edits, or deletes a note, every other connected client sees the change instantly — no polling, no WebSocket setup, no cache invalidation.

Create src/app/page.tsx:

"use client";
 
import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { useState } from "react";
import { Id } from "../../convex/_generated/dataModel";
 
// For demo purposes, we use a hardcoded userId.
// In production, this comes from your auth provider (see Step 9).
const USER_ID = "demo-user";
 
export default function NotesApp() {
  const notes = useQuery(api.notes.list, { userId: USER_ID });
  const createNote = useMutation(api.notes.create);
  const updateNote = useMutation(api.notes.update);
  const togglePin = useMutation(api.notes.togglePin);
  const removeNote = useMutation(api.notes.remove);
 
  const [editingId, setEditingId] = useState<Id<"notes"> | null>(null);
  const [searchTerm, setSearchTerm] = useState("");
 
  const searchResults = useQuery(
    api.notes.search,
    searchTerm.length > 0 ? { userId: USER_ID, searchTerm } : "skip"
  );
 
  const displayNotes = searchTerm.length > 0 ? searchResults : notes;
 
  const handleCreate = async () => {
    await createNote({
      userId: USER_ID,
      title: "Untitled Note",
      content: "",
    });
  };
 
  if (notes === undefined) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
      </div>
    );
  }
 
  return (
    <main className="max-w-4xl mx-auto p-6">
      <header className="flex items-center justify-between mb-8">
        <h1 className="text-3xl font-bold">Convex Notes</h1>
        <button
          onClick={handleCreate}
          className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
        >
          + New Note
        </button>
      </header>
 
      {/* Search bar */}
      <input
        type="text"
        placeholder="Search notes..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        className="w-full p-3 border rounded-lg mb-6 focus:outline-none focus:ring-2 focus:ring-blue-500"
      />
 
      {/* Notes grid */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {displayNotes?.map((note) => (
          <div
            key={note._id}
            className={`border rounded-lg p-4 hover:shadow-md transition ${
              note.isPinned ? "border-yellow-400 bg-yellow-50" : ""
            }`}
          >
            {editingId === note._id ? (
              <EditNoteForm
                note={note}
                onSave={async (title, content) => {
                  await updateNote({
                    noteId: note._id,
                    title,
                    content,
                  });
                  setEditingId(null);
                }}
                onCancel={() => setEditingId(null)}
              />
            ) : (
              <>
                <div className="flex items-start justify-between mb-2">
                  <h2 className="font-semibold text-lg">{note.title}</h2>
                  <button
                    onClick={() => togglePin({ noteId: note._id })}
                    className="text-xl"
                    title={note.isPinned ? "Unpin" : "Pin"}
                  >
                    {note.isPinned ? "📌" : "📍"}
                  </button>
                </div>
                <p className="text-gray-600 mb-4 line-clamp-3">
                  {note.content || "Empty note..."}
                </p>
                {note.fileName && (
                  <p className="text-sm text-blue-500 mb-2">
                    📎 {note.fileName}
                  </p>
                )}
                <div className="flex gap-2">
                  <button
                    onClick={() => setEditingId(note._id)}
                    className="text-sm text-blue-600 hover:underline"
                  >
                    Edit
                  </button>
                  <button
                    onClick={() => removeNote({ noteId: note._id })}
                    className="text-sm text-red-600 hover:underline"
                  >
                    Delete
                  </button>
                </div>
              </>
            )}
          </div>
        ))}
      </div>
 
      {displayNotes?.length === 0 && (
        <p className="text-center text-gray-400 mt-12">
          No notes yet. Click &quot;+ New Note&quot; to get started.
        </p>
      )}
    </main>
  );
}
 
function EditNoteForm({
  note,
  onSave,
  onCancel,
}: {
  note: { title: string; content: string };
  onSave: (title: string, content: string) => Promise<void>;
  onCancel: () => void;
}) {
  const [title, setTitle] = useState(note.title);
  const [content, setContent] = useState(note.content);
 
  return (
    <div className="space-y-2">
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
      />
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        rows={4}
        className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
      />
      <div className="flex gap-2">
        <button
          onClick={() => onSave(title, content)}
          className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700"
        >
          Save
        </button>
        <button
          onClick={onCancel}
          className="text-gray-500 px-3 py-1 rounded text-sm hover:bg-gray-100"
        >
          Cancel
        </button>
      </div>
    </div>
  );
}

The key insight here is useQuery. When you call useQuery(api.notes.list, { userId: USER_ID }), Convex:

  1. Runs your query function on the server
  2. Sends the result to the client
  3. Subscribes to all tables touched by that query
  4. Automatically re-runs the query and pushes new results whenever the data changes

This is fundamentally different from REST APIs or even GraphQL subscriptions — there is no manual cache invalidation, no optimistic updates to manage, and no stale data.

Also notice the "skip" parameter: passing "skip" instead of args tells Convex to skip running the query entirely, which is useful for conditional queries like our search.

Step 8: Add File Uploads

Convex has built-in file storage. You can upload files directly from the client and reference them in your documents. Add a file upload mutation to convex/notes.ts:

// Generate an upload URL for the client
export const generateUploadUrl = mutation({
  handler: async (ctx) => {
    return await ctx.storage.generateUploadUrl();
  },
});
 
// Attach a file to a note
export const attachFile = mutation({
  args: {
    noteId: v.id("notes"),
    fileId: v.id("_file_storage"),
    fileName: v.string(),
  },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.noteId, {
      fileId: args.fileId,
      fileName: args.fileName,
    });
  },
});

Now create a file upload component at src/components/FileUpload.tsx:

"use client";
 
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";
import { useRef } from "react";
 
export default function FileUpload({ noteId }: { noteId: Id<"notes"> }) {
  const generateUploadUrl = useMutation(api.notes.generateUploadUrl);
  const attachFile = useMutation(api.notes.attachFile);
  const fileInputRef = useRef<HTMLInputElement>(null);
 
  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
 
    // Step 1: Get a short-lived upload URL from Convex
    const uploadUrl = await generateUploadUrl();
 
    // Step 2: POST the file to the upload URL
    const response = await fetch(uploadUrl, {
      method: "POST",
      headers: { "Content-Type": file.type },
      body: file,
    });
 
    const { storageId } = await response.json();
 
    // Step 3: Save the file reference in the note
    await attachFile({
      noteId,
      fileId: storageId,
      fileName: file.name,
    });
  };
 
  return (
    <div>
      <input
        type="file"
        ref={fileInputRef}
        onChange={handleUpload}
        className="hidden"
      />
      <button
        onClick={() => fileInputRef.current?.click()}
        className="text-sm text-gray-500 hover:text-gray-700"
      >
        📎 Attach file
      </button>
    </div>
  );
}

The upload flow is three steps: (1) get a signed upload URL from your mutation, (2) POST the file directly to Convex storage, (3) save the returned storageId in your document. Files are served via Convex CDN automatically.

Step 9: Add Authentication with Clerk

For production apps, you need to scope data per user. Convex integrates seamlessly with Clerk, Auth0, and other auth providers. Here we will use Clerk.

Install the Clerk packages:

npm install @clerk/nextjs

Sign up for a free Clerk account at clerk.com, create an application, and add your keys to .env.local:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud

Update your Convex client provider to integrate with Clerk. Replace src/components/ConvexClientProvider.tsx:

"use client";
 
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { ConvexReactClient } from "convex/react";
import { ClerkProvider, useAuth } from "@clerk/nextjs";
import { ReactNode } from "react";
 
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
 
export default function ConvexClientProvider({
  children,
}: {
  children: ReactNode;
}) {
  return (
    <ClerkProvider>
      <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
        {children}
      </ConvexProviderWithClerk>
    </ClerkProvider>
  );
}

Now configure Convex to verify Clerk tokens. Create convex/auth.config.ts:

export default {
  providers: [
    {
      domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
      applicationID: "convex",
    },
  ],
};

Set the CLERK_JWT_ISSUER_DOMAIN environment variable in your Convex dashboard (Settings then Environment Variables). The value is your Clerk Frontend API URL, something like https://your-app.clerk.accounts.dev.

Now update your queries and mutations to use authenticated users. Replace args: { userId: v.string() } with identity checks:

import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
 
export const list = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return [];
 
    const userId = identity.subject;
    const notes = await ctx.db
      .query("notes")
      .withIndex("by_user", (q) => q.eq("userId", userId))
      .order("desc")
      .collect();
 
    return notes.sort((a, b) => {
      if (a.isPinned && !b.isPinned) return -1;
      if (!a.isPinned && b.isPinned) return 1;
      return 0;
    });
  },
});

With ctx.auth.getUserIdentity(), Convex automatically verifies the JWT token from Clerk. Each user only sees their own notes — enforced at the database query level, not just the UI.

Step 10: Add Optimistic Updates

While Convex is already fast (updates typically arrive within 20-50ms), you can make the UI feel even snappier with optimistic updates. These predict the mutation result and update the UI immediately, before the server confirms.

"use client";
 
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { optimisticUpdate } from "convex/react";
 
// In your component:
const createNote = useMutation(api.notes.create).withOptimisticUpdate(
  (localStore, args) => {
    const existingNotes = localStore.getQuery(api.notes.list, {});
    if (existingNotes === undefined) return;
 
    localStore.setQuery(api.notes.list, {}, [
      {
        _id: crypto.randomUUID() as any,
        _creationTime: Date.now(),
        userId: args.userId,
        title: args.title,
        content: args.content,
        isPinned: false,
      },
      ...existingNotes,
    ]);
  }
);

Optimistic updates work by modifying the local query cache. When the server confirms the mutation, Convex replaces the optimistic result with the real one. If the mutation fails, the optimistic update is automatically rolled back.

Step 11: Deploy to Production

Convex separates development and production deployments. Your npx convex dev deployment is for development. For production:

# Deploy your backend to production
npx convex deploy
 
# Build and deploy your Next.js frontend
npm run build

Set your production environment variables:

NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud

Your Convex backend is now running on Convex's managed infrastructure — globally distributed, auto-scaling, and with automatic backups. There is no server to manage, no database to tune, and no infrastructure to worry about.

Testing Your Implementation

  1. Real-time sync: Open the app in two browser windows side by side. Create a note in one window — it should appear instantly in the other
  2. Search: Type in the search bar and verify results filter in real time
  3. Pin/unpin: Pin a note and verify it moves to the top of the list across all windows
  4. File uploads: Attach a file to a note and verify it persists after page reload
  5. Authentication: Sign out and verify notes are not accessible. Sign in with a different account and verify data is isolated

How Convex Compares to Traditional Backends

FeatureTraditional StackConvex
Real-time updatesManual WebSocket setupAutomatic with useQuery
Type safetySeparate ORM types + API typesEnd-to-end from schema to client
TransactionsManual transaction managementEvery mutation is transactional
CachingRedis, query invalidationAutomatic reactive cache
File storageS3 + signed URLsBuilt-in ctx.storage
DeploymentDocker, Kubernetes, etc.npx convex deploy
ScalingManual horizontal scalingAutomatic

Troubleshooting

"Could not find module convex/_generated": Run npx convex dev to generate the types. The _generated directory is created automatically when you first run the dev server.

Queries return undefined: useQuery returns undefined while loading. Always handle the loading state before accessing data.

"Not authenticated" errors: Make sure your Clerk JWT issuer domain is set correctly in Convex environment variables, and that your auth.config.ts matches.

Schema push fails: Check that your schema types match existing data. Convex enforces schema validation on deploy — you may need to migrate existing data first.

Next Steps

  • Add rich text editing with Tiptap or Slate for a more Notion-like experience
  • Implement sharing — allow users to share notes via unique links
  • Add Convex scheduled functions for features like reminders or auto-archiving old notes
  • Explore Convex actions for calling external APIs (email notifications, AI summarization)
  • Set up Convex components to modularize your backend logic

Conclusion

You have built a fully reactive, real-time full-stack application with Convex and Next.js 15. The key takeaways:

  • Convex eliminates glue code — no REST endpoints, no ORM setup, no WebSocket configuration
  • Real-time is the default — every useQuery automatically subscribes to live updates
  • TypeScript all the way — schema validators generate types that flow from database to UI
  • Transactions are automatic — every mutation is serializable, eliminating race conditions
  • File storage is built in — no separate S3 bucket or CDN configuration needed

Convex represents a shift in how we think about backends: instead of building infrastructure to move data between a database and a frontend, you write TypeScript functions that read and write data, and Convex handles everything else — real-time sync, caching, scaling, and deployment.


Want to read more tutorials? Check out our latest tutorial on Building a Full-Stack Web App with SolidStart: A Complete Hands-On Guide.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles