BETTER-CONVEX

Quickstart

Scaffold a Next.js app with kitcn, bootstrap a local backend, and understand the starter messages demo.

In this guide, we'll take the fastest happy path: scaffold a fresh Next.js app with the CLI, run the first local Convex bootstrap in the same command, and walk through the starter messages demo from schema to client.

If you want the full command map, start with /docs/cli/registry. This guide stays on the shortest path.

Docs use the ORM (ctx.orm) everywhere. The scaffolded starter demo keeps the backend tiny on purpose, so it uses direct Convex reads and writes. Once the app is running, you can move deeper into the ORM guides and migration docs.

1. Scaffold A Fresh App

Let's start with an empty directory and let the CLI own the bootstrap.

mkdir my-app
cd my-app
bunx kitcn init -t next --yes

init -t next creates the Next.js shell and layers in the kitcn baseline in one pass. That includes the backend files, client providers, local env scaffolding, and a tiny messages demo so you have something real to run immediately.

Here are the key files the quickstart uses:

FilePurpose
convex/functions/schema.tsStarter schema
convex/functions/messages.tsStarter query and mutation
convex/lib/crpc.tsServer-side cRPC builders
lib/convex/crpc.tsxReact cRPC context
components/providers.tsxApp-level providers
app/convex/page.tsxStarter demo page

If you already have a supported app shell, use kitcn init --yes instead. That adoption path is documented in /docs/cli/registry.

2. Run The App

Now let's start the backend for real and open the Next.js app in a second terminal.

terminal 1
bunx kitcn dev
terminal 2
bun dev

Open http://localhost:3000/convex. You should see the starter messages page. Add a message and watch the list update.

That gives you the full end-to-end loop:

  • query from React
  • mutation from React
  • live backend round-trip
  • generated API metadata through @convex/api

If the page says the backend is not ready, check the terminal running kitcn dev. The starter page already includes that empty/error state, so you do not have to guess what failed.

3. Read The Starter Backend

Now that the app is running, let's trace the files you just bootstrapped.

Schema

We'll start with the starter schema. It is intentionally tiny.

convex/functions/schema.ts
import { convexTable, defineSchema, text } from 'kitcn/orm';

export const messagesTable = convexTable('messages', {
  body: text().notNull(),
});

export const tables = {
  messages: messagesTable,
};

export default defineSchema(tables);

This gives the demo one messages table with a single required body field. That is enough to prove the full query and mutation loop without burying the quickstart in schema noise.

Procedures

Next, let's look at the starter procedures.

convex/functions/messages.ts
import { z } from 'zod';

import { publicMutation, publicQuery } from '../lib/crpc';

export const list = publicQuery
  .output(
    z.array(
      z.object({
        id: z.string(),
        body: z.string(),
        createdAt: z.date(),
      })
    )
  )
  .query(async ({ ctx }) => {
    const rows = await ctx.db.query('messages').order('desc').take(10);

    return rows.map((row) => ({
      id: row._id,
      body: row.body,
      createdAt: new Date(row._creationTime),
    }));
  });

export const create = publicMutation
  .input(z.object({ body: z.string().trim().min(1).max(120) }))
  .output(z.string())
  .mutation(async ({ ctx, input }) =>
    await ctx.db.insert('messages', { body: input.body })
  );

The pattern is simple:

  • publicQuery reads data
  • publicMutation writes data
  • zod defines the contract
  • the return value is shaped for the client immediately

The starter demo uses direct ctx.db calls because they are easy to read. The rest of the docs build on top of ctx.orm once you need richer schemas, relations, and typed query helpers.

cRPC builder

The builders come from the scaffolded cRPC file:

convex/lib/crpc.ts
import { initCRPC } from '../functions/generated/server';

const c = initCRPC.create();

export const publicQuery = c.query;
export const publicAction = c.action;
export const publicMutation = c.mutation;

export const privateQuery = c.query.internal();
export const privateMutation = c.mutation.internal();
export const privateAction = c.action.internal();

That file is part of the baseline. You do not need to hand-roll cRPC setup in the quickstart. init already gave you the builders the starter app needs.

4. Read The Starter Client

The client side is just as small.

React cRPC context

We'll start with the generated client context:

lib/convex/crpc.tsx
import { api } from '@convex/api';
import { createCRPCContext } from 'kitcn/react';

export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
  api,
  convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});

@convex/api is the generated metadata surface. createCRPCContext(...) turns that into typed TanStack Query helpers for your React app.

Demo page

Now let's look at the starter page you just opened:

app/convex/page.tsx
'use client';

import { useMutation, useQuery } from '@tanstack/react-query';
import { type FormEvent, useState } from 'react';

import { Button } from '@/components/ui/button';
import { useCRPC } from '@/lib/convex/crpc';

export default function ConvexMessagesPage() {
  const crpc = useCRPC();
  const [draft, setDraft] = useState('');
  const messagesQuery = useQuery(crpc.messages.list.queryOptions());
  const createMessage = useMutation(crpc.messages.create.mutationOptions());

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = draft.trim();
    if (!body) return;

    try {
      await createMessage.mutateAsync({ body });
      setDraft('');
    } catch {}
  }

  return (
    <main>
      <form onSubmit={handleSubmit}>
        <input
          onChange={(event) => setDraft(event.target.value)}
          value={draft}
        />
        <Button disabled={createMessage.isPending} type="submit">
          {createMessage.isPending ? 'Saving...' : 'Add message'}
        </Button>
      </form>

      {messagesQuery.isPending ? (
        <p>Loading messages...</p>
      ) : messagesQuery.isError ? (
        <div>Backend not ready. Start kitcn dev and refresh.</div>
      ) : (
        <ul>
          {messagesQuery.data.map((message) => (
            <li key={message.id}>{message.body}</li>
          ))}
        </ul>
      )}
    </main>
  );
}

This is the starter loop you will reuse everywhere:

  • useCRPC() gives you typed procedure handles
  • useQuery(...) reads data from a query procedure
  • useMutation(...) calls a mutation procedure
  • the UI handles loading, error, and success states in the same file

5. What To Build Next

You now have a running kitcn app, a local deployment, generated API types, and one live page wired end to end. That's the real starting point.

From here, pick the next seam you actually need:

On this page