Features

Querying Data

Vextro manages your content through the admin panel, but your frontend application needs to read that content too. Since Vextro collections are regular Convex tables, you query them with standard Convex functions -- no special API layer required.

New to Convex?

If you have not used Convex before, read the Key Concepts page first. It explains queries, mutations, real-time subscriptions, and indexes.

The Convex client

Convex provides two types of clients for different use cases:

  • ConvexHttpClient -- makes one-shot HTTP requests. Use this for server-side rendering (Astro frontmatter, SvelteKit load functions, Next.js Server Components) where you need data at request time but do not need real-time updates.
  • ConvexClient / ConvexProvider -- maintains a WebSocket connection with real-time subscriptions. Use this for client-side interactivity where data should update live.

Convex client documentation

Writing query functions

Write a Convex query function that reads from a Vextro-managed table. This function lives in your convex/ directory alongside your Vextro configuration.

// convex/public.ts
import { query } from "./_generated/server";
import { v } from "convex/values";

export const listPublishedPosts = query({
  args: { limit: v.optional(v.number()) },
  returns: v.array(v.any()),
  handler: async (ctx, { limit }) => {
    return await ctx.db
      .query("posts")
      .withIndex("by_status", (q) => q.eq("status", "published"))
      .order("desc")
      .take(limit ?? 20);
  },
});

Regular Convex tables

Vextro collections are standard Convex tables. Query them with ctx.db exactly like any other table. The f field builders generate table definitions and indexes -- the data itself is plain Convex documents.

Querying from your frontend

How you call a Convex query depends on your framework. Here are examples for the most common setups.

In Astro, fetch data in the frontmatter using ConvexHttpClient. This runs at build time (static) or request time (SSR).

---
// src/pages/blog.astro
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../convex/_generated/api";

const convex = new ConvexHttpClient(import.meta.env.PUBLIC_CONVEX_URL);
const posts = await convex.query(api.public.listPublishedPosts, { limit: 10 });
---

<ul>
  {posts.map((post) => (
    <li>
      <a href={`/blog/${post.slug}`}>{post.title}</a>
    </li>
  ))}
</ul>

For client-side interactivity within Astro, use a Svelte or React island with the reactive Convex client.

Use convex-svelte for reactive queries that update in real time.

<!-- src/routes/blog/+page.svelte -->
<script lang="ts">
  import { useQuery } from "convex-svelte";
  import { api } from "../../convex/_generated/api";

  const posts = useQuery(api.public.listPublishedPosts, { limit: 10 });
</script>

{#if $posts.isLoading}
  <p>Loading...</p>
{:else if $posts.data}
  <ul>
    {#each $posts.data as post}
      <li>
        <a href="/blog/{post.slug}">{post.title}</a>
      </li>
    {/each}
  </ul>
{/if}

For SvelteKit server-side loading, use ConvexHttpClient in a +page.server.ts load function:

// src/routes/blog/+page.server.ts
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../convex/_generated/api";
import { CONVEX_URL } from "$env/static/private";

export async function load() {
  const convex = new ConvexHttpClient(CONVEX_URL);
  const posts = await convex.query(api.public.listPublishedPosts, { limit: 10 });
  return { posts };
}

Use the convex/react provider for reactive client-side queries.

// app/blog/page.tsx (Server Component)
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../convex/_generated/api";

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

export default async function BlogPage() {
  const posts = await convex.query(api.public.listPublishedPosts, { limit: 10 });

  return (
    <ul>
      {posts.map((post) => (
        <li key={post._id}>
          <a href={`/blog/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}

For client-side reactivity, wrap your app with ConvexProvider and use the useQuery hook:

"use client";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";

export function PostList() {
  const posts = useQuery(api.public.listPublishedPosts, { limit: 10 });

  if (posts === undefined) return <p>Loading...</p>;

  return (
    <ul>
      {posts.map((post) => (
        <li key={post._id}>
          <a href={`/blog/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}

Querying a single document

Use a by_slug index to look up a single document. Vextro auto-generates this index when your collection has a slug field.

// convex/public.ts
export const getPostBySlug = query({
  args: { slug: v.string() },
  returns: v.union(v.any(), v.null()),
  handler: async (ctx, { slug }) => {
    return await ctx.db
      .query("posts")
      .withIndex("by_slug", (q) => q.eq("slug", slug))
      .unique();
  },
});
---
// src/pages/blog/[slug].astro
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../../convex/_generated/api";

const convex = new ConvexHttpClient(import.meta.env.PUBLIC_CONVEX_URL);
const { slug } = Astro.params;
const post = await convex.query(api.public.getPostBySlug, { slug: slug! });

if (!post) return Astro.redirect("/404");
---

<article>
  <h1>{post.title}</h1>
  <Fragment set:html={post.content} />
</article>
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
  import { useQuery } from "convex-svelte";
  import { api } from "../../../../convex/_generated/api";
  import { page } from "$app/stores";

  $: postQuery = useQuery(api.public.getPostBySlug, { slug: $page.params.slug });
</script>

{#if $postQuery.data}
  <article>
    <h1>{$postQuery.data.title}</h1>
    {@html $postQuery.data.content}
  </article>
{:else}
  <p>Loading...</p>
{/if}
// app/blog/[slug]/page.tsx
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../../../convex/_generated/api";
import { notFound } from "next/navigation";

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

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await convex.query(api.public.getPostBySlug, { slug: params.slug });
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Pagination

For large collections, use Convex's built-in pagination. This returns a page of results along with a cursor for the next page.

// convex/public.ts
import { paginationOptsValidator } from "convex/server";

export const listPostsPaginated = query({
  args: { paginationOpts: paginationOptsValidator },
  returns: v.any(),
  handler: async (ctx, { paginationOpts }) => {
    return await ctx.db
      .query("posts")
      .withIndex("by_status", (q) => q.eq("status", "published"))
      .order("desc")
      .paginate(paginationOpts);
  },
});

The result includes page (array of documents), isDone (boolean), and continueCursor (string). Pass the cursor back to fetch the next page:

---
const convex = new ConvexHttpClient(import.meta.env.PUBLIC_CONVEX_URL);

// First page
const result = await convex.query(api.public.listPostsPaginated, {
  paginationOpts: { numItems: 10, cursor: null },
});

// Next page (if needed)
if (!result.isDone) {
  const page2 = await convex.query(api.public.listPostsPaginated, {
    paginationOpts: { numItems: 10, cursor: result.continueCursor },
  });
}
---
<script lang="ts">
  import { useQuery } from "convex-svelte";
  import { api } from "../../convex/_generated/api";

  let cursor: string | null = null;

  $: postsQuery = useQuery(api.public.listPostsPaginated, {
    paginationOpts: { numItems: 10, cursor },
  });

  function loadMore() {
    if ($postsQuery.data && !$postsQuery.data.isDone) {
      cursor = $postsQuery.data.continueCursor;
    }
  }
</script>
"use client";
import { usePaginatedQuery } from "convex/react";
import { api } from "../../convex/_generated/api";

export function PaginatedPosts() {
  const { results, status, loadMore } = usePaginatedQuery(
    api.public.listPostsPaginated,
    {},
    { initialNumItems: 10 },
  );

  return (
    <div>
      <ul>
        {results.map((post) => (
          <li key={post._id}>{post.title}</li>
        ))}
      </ul>
      {status === "CanLoadMore" && (
        <button onClick={() => loadMore(10)}>Load more</button>
      )}
    </div>
  );
}

Convex pagination documentation

Resolving relationships

When a field uses f.id("users"), Convex stores a document ID reference. Unlike PayloadCMS's depth parameter that auto-populates relationships, Convex requires you to resolve references explicitly in your query.

// convex/public.ts
export const getPostWithAuthor = query({
  args: { slug: v.string() },
  returns: v.union(v.any(), v.null()),
  handler: async (ctx, { slug }) => {
    const post = await ctx.db
      .query("posts")
      .withIndex("by_slug", (q) => q.eq("slug", slug))
      .unique();

    if (!post) return null;

    // Resolve the author relationship
    const author = post.author ? await ctx.db.get(post.author) : null;

    return {
      ...post,
      author: author ? { name: author.name, email: author.email } : null,
    };
  },
});

Explicit is better

Convex does not have a depth parameter like PayloadCMS. You choose exactly which relationships to resolve and which fields to include. This avoids the performance pitfalls of deeply nested auto-population and gives you full control over the response shape.

Reading globals

Globals are singleton documents (site settings, navigation menus, feature flags). Query them from your frontend the same way you query collections.

// convex/public.ts
export const getSiteSettings = query({
  args: {},
  returns: v.union(v.any(), v.null()),
  handler: async (ctx) => {
    return await ctx.db
      .query("site_settings")
      .order("desc")
      .first();
  },
});
---
// src/layouts/BaseLayout.astro
const convex = new ConvexHttpClient(import.meta.env.PUBLIC_CONVEX_URL);
const settings = await convex.query(api.public.getSiteSettings);
---

<html>
  <head>
    <title>{settings?.siteName ?? "My Site"}</title>
  </head>
  <body>
    <slot />
  </body>
</html>
<script lang="ts">
  import { useQuery } from "convex-svelte";
  import { api } from "../../convex/_generated/api";

  const settings = useQuery(api.public.getSiteSettings);
</script>

<svelte:head>
  <title>{$settings.data?.siteName ?? "My Site"}</title>
</svelte:head>
// app/layout.tsx
import { ConvexHttpClient } from "convex/browser";
import { api } from "../convex/_generated/api";

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

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const settings = await convex.query(api.public.getSiteSettings);

  return (
    <html>
      <head>
        <title>{settings?.siteName ?? "My Site"}</title>
      </head>
      <body>{children}</body>
    </html>
  );
}

Next steps

Previous
Section