or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

advanced.mdauth.mddatabase.mdindex.mdnextjs.mdreact.mdschema.mdserver-functions.mdvalues-validators.md
tile.json

nextjs.mddocs/

Next.js Integration

Setup

// app/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 function ConvexClientProvider({ children }: { children: ReactNode }) {
  return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}

// app/layout.tsx
import { ConvexClientProvider } from './ConvexClientProvider';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ConvexClientProvider>{children}</ConvexClientProvider>
      </body>
    </html>
  );
}

Server Components

preloadQuery - SSR with Reactivity

// app/posts/page.tsx (Server Component)
import { preloadQuery } from 'convex/nextjs';
import { api } from '@/convex/_generated/api';
import PostList from './PostList';

export default async function PostsPage() {
  // Fetch on server
  const preloadedPosts = await preloadQuery(api.posts.list);

  return (
    <div>
      <h1>Posts</h1>
      <PostList preloadedPosts={preloadedPosts} />
    </div>
  );
}

// app/posts/PostList.tsx (Client Component)
'use client';
import { usePreloadedQuery, Preloaded } from 'convex/react';
import { api } from '@/convex/_generated/api';

export default function PostList({
  preloadedPosts,
}: {
  preloadedPosts: Preloaded<typeof api.posts.list>;
}) {
  // Hydrates with server data, then stays reactive
  const posts = usePreloadedQuery(preloadedPosts);

  return (
    <div>
      {posts.map(post => (
        <article key={post._id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </article>
      ))}
    </div>
  );
}

// With arguments
export default async function UserPage({ params }) {
  const preloaded = await preloadQuery(api.users.get, { id: params.userId });
  return <UserProfile preloaded={preloaded} />;
}

fetchQuery - One-time Server Fetch

// app/posts/[id]/page.tsx
import { fetchQuery } from 'convex/nextjs';
import { api } from '@/convex/_generated/api';

export default async function PostPage({ params }) {
  const post = await fetchQuery(api.posts.get, { id: params.id });

  if (!post) return <div>Post not found</div>;

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

// Generate static params
export async function generateStaticParams() {
  const posts = await fetchQuery(api.posts.list, {});
  return posts.map(post => ({ id: post._id }));
}

// API Route
// app/api/stats/route.ts
import { fetchQuery } from 'convex/nextjs';
import { api } from '@/convex/_generated/api';
import { NextResponse } from 'next/server';

export async function GET() {
  const stats = await fetchQuery(api.stats.get, {});
  return NextResponse.json(stats);
}

Server Actions

fetchMutation - Form Actions

// app/posts/create/page.tsx
import { fetchMutation } from 'convex/nextjs';
import { api } from '@/convex/_generated/api';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export default function CreatePostPage() {
  async function createPost(formData: FormData) {
    'use server';

    const title = formData.get('title') as string;
    const body = formData.get('body') as string;

    const postId = await fetchMutation(api.posts.create, { title, body });

    revalidatePath('/posts');
    redirect(`/posts/${postId}`);
  }

  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" required />
      <textarea name="body" placeholder="Body" required />
      <button type="submit">Create Post</button>
    </form>
  );
}

// Alternative: separate server action file
// app/actions.ts
'use server';
import { fetchMutation } from 'convex/nextjs';
import { api } from '@/convex/_generated/api';

export async function deletePost(postId: string) {
  await fetchMutation(api.posts.delete, { id: postId });
  revalidatePath('/posts');
}

// Use in client component
'use client';
import { deletePost } from './actions';

function DeleteButton({ postId }: { postId: string }) {
  return (
    <button onClick={() => deletePost(postId)}>
      Delete
    </button>
  );
}

fetchAction - External APIs

'use server';
import { fetchAction } from 'convex/nextjs';
import { api } from '@/convex/_generated/api';

export async function sendEmail(to: string, subject: string, body: string) {
  await fetchAction(api.emails.send, { to, subject, body });
}

export async function generateReport(userId: string) {
  return await fetchAction(api.reports.generate, { userId });
}

Authentication

With Clerk

// Server Component
import { auth } from '@clerk/nextjs';
import { preloadQuery } from 'convex/nextjs';
import { api } from '@/convex/_generated/api';

export default async function PrivatePage() {
  const { getToken } = auth();
  const token = await getToken({ template: 'convex' });

  const preloadedData = await preloadQuery(
    api.users.getCurrent,
    {},
    { token }
  );

  return <UserProfile preloaded={preloadedData} />;
}

// Server Action
async function updateProfile(formData: FormData) {
  'use server';

  const { getToken } = auth();
  const token = await getToken({ template: 'convex' });

  const bio = formData.get('bio') as string;

  await fetchMutation(
    api.users.updateProfile,
    { bio },
    { token }
  );

  revalidatePath('/profile');
}

// Client Provider
// app/ConvexClientProvider.tsx
'use client';
import { ConvexProviderWithClerk } from 'convex/react-clerk';
import { ConvexReactClient } from 'convex/react';
import { ClerkProvider, useAuth } from '@clerk/nextjs';

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export function ConvexClientProvider({ children }) {
  return (
    <ClerkProvider>
      <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
        {children}
      </ConvexProviderWithClerk>
    </ClerkProvider>
  );
}

With Auth0

// Similar pattern with Auth0
import { ConvexProviderWithAuth0 } from 'convex/react-auth0';
import { Auth0Provider } from '@auth0/auth0-react';

// In client provider
<Auth0Provider domain="..." clientId="...">
  <ConvexProviderWithAuth0 client={convex}>
    {children}
  </ConvexProviderWithAuth0>
</Auth0Provider>

API Routes

// app/api/webhooks/stripe/route.ts
import { fetchMutation } from 'convex/nextjs';
import { api } from '@/convex/_generated/api';
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const body = await request.json();

  // Verify webhook signature
  const signature = request.headers.get('stripe-signature');
  if (!verifySignature(signature, body)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // Process via Convex
  await fetchMutation(api.webhooks.processStripe, body);

  return NextResponse.json({ success: true });
}

// app/api/data/route.ts - With auth
import { auth } from '@clerk/nextjs';

export async function GET() {
  const { getToken } = auth();
  const token = await getToken({ template: 'convex' });

  const data = await fetchQuery(api.data.getPrivate, {}, { token });

  return NextResponse.json(data);
}

Configuration Options

// All server-side functions support these options
const options = {
  token: 'jwt-token-string',           // For user authentication
  adminToken: process.env.CONVEX_DEPLOY_KEY,  // For admin operations
  url: 'https://custom.convex.cloud',  // Override deployment URL
  skipConvexDeploymentUrlCheck: true,  // For development
};

await preloadQuery(api.fn, args, options);
await fetchQuery(api.fn, args, options);
await fetchMutation(api.fn, args, options);
await fetchAction(api.fn, args, options);

Complete Example

// app/layout.tsx
import { ConvexClientProvider } from './ConvexClientProvider';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ConvexClientProvider>{children}</ConvexClientProvider>
      </body>
    </html>
  );
}

// app/posts/page.tsx (Server Component)
import { preloadQuery } from 'convex/nextjs';
import { api } from '@/convex/_generated/api';
import PostList from './PostList';
import CreatePostForm from './CreatePostForm';

export default async function PostsPage() {
  const preloadedPosts = await preloadQuery(api.posts.list);

  return (
    <main>
      <h1>Posts</h1>
      <CreatePostForm />
      <PostList preloadedPosts={preloadedPosts} />
    </main>
  );
}

// app/posts/PostList.tsx (Client Component - reactive)
'use client';
import { usePreloadedQuery } from 'convex/react';
import { api } from '@/convex/_generated/api';

export default function PostList({ preloadedPosts }) {
  const posts = usePreloadedQuery(preloadedPosts);

  return (
    <div>
      {posts.map(post => (
        <article key={post._id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </article>
      ))}
    </div>
  );
}

// app/posts/CreatePostForm.tsx (Client Component with Server Action)
'use client';
import { createPost } from './actions';

export default function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" required />
      <textarea name="body" placeholder="Body" required />
      <button type="submit">Create</button>
    </form>
  );
}

// app/posts/actions.ts (Server Actions)
'use server';
import { fetchMutation } from 'convex/nextjs';
import { api } from '@/convex/_generated/api';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const body = formData.get('body') as string;

  const postId = await fetchMutation(api.posts.create, { title, body });

  revalidatePath('/posts');
  redirect(`/posts/${postId}`);
}

export async function deletePost(postId: string) {
  await fetchMutation(api.posts.delete, { id: postId });
  revalidatePath('/posts');
}