TinyKit Pro Docs

Testimonials Management

TinyKit Pro includes a flexible testimonials system that supports both Twitter/X tweet embeds and custom testimonials, perfect for showcasing social proof on...

TinyKit Pro includes a flexible testimonials system that supports both Twitter/X tweet embeds and custom testimonials, perfect for showcasing social proof on your landing pages.

🎯 Features

Dual Testimonial Types

  • Tweet Testimonials: Embed real tweets from Twitter/X with one click
  • Custom Testimonials: Full control with text, author info, ratings, and avatars
  • Unified Management: Single interface for both types
  • Rich Metadata: Author names, handles, companies, images, and ratings

Display Control

  • Active/Inactive Toggle: Quickly publish or hide testimonials
  • Custom Ordering: Control display order with drag-and-drop or manual positioning
  • Featured Testimonials: Highlight special testimonials prominently
  • Auto-Normalization: Automatic gap elimination in display order

Asset Management

  • Avatar Storage: Upload avatars to Convex storage or use external URLs
  • Tweet Integration: Automatic tweet rendering via react-tweet
  • Flexible Display: Choose between grid, carousel, or masonry layouts

🏗️ Architecture

Database Schema

testimonials: defineTable({
  // Type control
  type: v.union(v.literal("tweet"), v.literal("custom")),

  // Tweet-specific fields
  tweetId: v.optional(v.string()),
  tweetUrl: v.optional(v.string()),

  // Custom testimonial fields
  authorName: v.optional(v.string()),
  authorHandle: v.optional(v.string()), // @username
  authorImage: v.optional(v.string()), // External URL
  authorImageId: v.optional(v.id("_storage")), // Convex storage
  authorCompany: v.optional(v.string()),
  content: v.optional(v.string()),
  rating: v.optional(v.number()), // 1-5 stars

  // Display control
  isActive: v.boolean(),
  displayOrder: v.number(),
  featured: v.optional(v.boolean()),

  // Metadata
  createdAt: v.number(),
  updatedAt: v.number(),
})
  .index("by_active", ["isActive"])
  .index("by_display_order", ["displayOrder"])
  .index("by_featured", ["featured"])
  .index("by_type", ["type"]);

🚀 Usage

Display Testimonials (Tweet Type)

import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { Tweet } from "react-tweet";

function TestimonialsSection() {
  const testimonials = useQuery(api.testimonials.public.queries.listActive);

  if (!testimonials || testimonials.length === 0) {
    return null;
  }

  return (
    <section className="container mx-auto py-20">
      <h2 className="text-3xl font-bold text-center mb-12">
        What People Are Saying
      </h2>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {testimonials.map((testimonial) => (
          <div key={testimonial._id}>
            {testimonial.type === "tweet" && testimonial.tweetId ? (
              <Tweet id={testimonial.tweetId} />
            ) : (
              <CustomTestimonialCard testimonial={testimonial} />
            )}
          </div>
        ))}
      </div>
    </section>
  );
}

Custom Testimonial Card

import { Star } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";

function CustomTestimonialCard({ testimonial }) {
  return (
    <Card>
      <CardContent className="pt-6">
        {/* Rating */}
        {testimonial.rating && (
          <div className="flex gap-1 mb-4">
            {Array.from({ length: 5 }).map((_, i) => (
              <Star
                key={i}
                className={`h-4 w-4 ${
                  i < testimonial.rating
                    ? "fill-primary text-primary"
                    : "text-muted"
                }`}
              />
            ))}
          </div>
        )}

        {/* Content */}
        <p className="text-muted-foreground mb-4">{testimonial.content}</p>

        {/* Author */}
        <div className="flex items-center gap-3">
          {testimonial.authorImageId ? (
            <img
              src={getImageUrl(testimonial.authorImageId)}
              alt={testimonial.authorName || "User"}
              className="w-10 h-10 rounded-full"
            />
          ) : testimonial.authorImage ? (
            <img
              src={testimonial.authorImage}
              alt={testimonial.authorName || "User"}
              className="w-10 h-10 rounded-full"
            />
          ) : (
            <div className="w-10 h-10 rounded-full bg-muted" />
          )}

          <div>
            <p className="font-semibold">{testimonial.authorName}</p>
            {testimonial.authorHandle && (
              <p className="text-sm text-muted-foreground">
                @{testimonial.authorHandle}
              </p>
            )}
            {testimonial.authorCompany && (
              <p className="text-sm text-muted-foreground">
                {testimonial.authorCompany}
              </p>
            )}
          </div>
        </div>
      </CardContent>
    </Card>
  );
}

Create Tweet Testimonial

const createTestimonial = useMutation(
  api.testimonials.private.mutations.create,
);

await createTestimonial({
  type: "tweet",
  tweetId: "1234567890", // Extract from tweet URL
  tweetUrl: "https://twitter.com/user/status/1234567890",
  isActive: true,
});

Create Custom Testimonial

const createTestimonial = useMutation(
  api.testimonials.private.mutations.create,
);

await createTestimonial({
  type: "custom",
  authorName: "Jane Doe",
  authorHandle: "janedoe",
  authorCompany: "Acme Corp",
  content: "This product changed my workflow completely!",
  rating: 5,
  featured: true,
  isActive: true,
});

⚙️ Admin Functions

Create Testimonial

const createTestimonial = useMutation(
  api.testimonials.private.mutations.create,
);

// Tweet testimonial
await createTestimonial({
  type: "tweet",
  tweetId: "1234567890",
  tweetUrl: "https://twitter.com/user/status/1234567890",
  isActive: true,
});

// Custom testimonial with all fields
await createTestimonial({
  type: "custom",
  authorName: "John Smith",
  authorHandle: "johnsmith",
  authorCompany: "Tech Startup Inc",
  authorImage: "https://example.com/avatar.jpg", // External URL
  content: "Amazing product! Highly recommended.",
  rating: 5,
  featured: true,
  isActive: true,
});

Update Testimonial

const updateTestimonial = useMutation(
  api.testimonials.private.mutations.update,
);

await updateTestimonial({
  id: testimonialId,
  content: "Updated testimonial text",
  rating: 4,
  isActive: true,
});

Upload Avatar to Convex Storage

const generateUploadUrl = useMutation(
  api.testimonials.private.mutations.generateUploadUrl,
);
const updateWithAvatar = useMutation(
  api.testimonials.private.mutations.updateWithAvatar,
);

// 1. Generate upload URL
const uploadUrl = await generateUploadUrl();

// 2. Upload file
const response = await fetch(uploadUrl, {
  method: "POST",
  body: file,
});
const { storageId } = await response.json();

// 3. Update testimonial with avatar
await updateWithAvatar({
  id: testimonialId,
  authorImageId: storageId,
});

Delete Testimonial

const removeTestimonial = useMutation(
  api.testimonials.private.mutations.remove,
);

await removeTestimonial({ id: testimonialId });
// Automatically normalizes display order

Toggle Active Status

const toggleActive = useMutation(
  api.testimonials.private.mutations.toggleActive,
);

await toggleActive({ id: testimonialId });

Reorder Testimonials

const reorderTestimonial = useMutation(
  api.testimonials.private.mutations.reorder,
);

await reorderTestimonial({
  id: testimonialId,
  newDisplayOrder: 2,
});

🎨 Implementation Patterns

function FeaturedTestimonials() {
  const featured = useQuery(api.testimonials.public.queries.listFeatured);

  if (!featured || featured.length === 0) {
    return null;
  }

  return (
    <section className="bg-primary/5 py-20">
      <div className="container mx-auto">
        <h2 className="text-4xl font-bold text-center mb-12">
          Featured Reviews
        </h2>

        <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-5xl mx-auto">
          {featured.map((testimonial) => (
            <TestimonialCard
              key={testimonial._id}
              testimonial={testimonial}
              variant="featured"
            />
          ))}
        </div>
      </div>
    </section>
  );
}

Tweet Wall

import { Tweet } from "react-tweet";

function TweetWall() {
  const tweets = useQuery(api.testimonials.public.queries.listByType, {
    type: "tweet",
  });

  if (!tweets || tweets.length === 0) {
    return null;
  }

  return (
    <section className="container mx-auto py-20">
      <h2 className="text-3xl font-bold text-center mb-12">
        What They're Saying on X
      </h2>

      <div className="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6">
        {tweets.map((tweet) => (
          <div key={tweet._id} className="break-inside-avoid">
            <Tweet id={tweet.tweetId!} />
          </div>
        ))}
      </div>
    </section>
  );
}

📊 Display Patterns

Display Order

  • Lower Numbers First: displayOrder: 1 before displayOrder: 2
  • Auto-Assignment: New testimonials get next available order
  • Auto-Normalization: Gaps automatically filled when deleting
  • Featured Priority: Featured testimonials can be shown separately

Filtering Options

// All active testimonials
const all = useQuery(api.testimonials.public.queries.listActive);

// Featured only
const featured = useQuery(api.testimonials.public.queries.listFeatured);

// By type
const tweets = useQuery(api.testimonials.public.queries.listByType, {
  type: "tweet",
});

const custom = useQuery(api.testimonials.public.queries.listByType, {
  type: "custom",
});

🔒 Access Control

All admin functions require proper authorization:

await requireAccess(ctx, {
  userRole: ["admin"],
});

Required Roles:

  • Admin: Full testimonial management
  • User: Read-only access to active testimonials (public)

📚 API Reference

Public Queries

listActive - Get all active testimonials sorted by display order listFeatured - Get featured testimonials only listByType - Get testimonials filtered by type (tweet/custom)

Private Mutations

create - Create new testimonial (tweet or custom) update - Update existing testimonial remove - Delete testimonial (auto-normalizes order) toggleActive - Toggle active status reorder - Change display order generateUploadUrl - Generate URL for avatar upload updateWithAvatar - Update testimonial with uploaded avatar

🧪 Testing Checklist

  • Create tweet testimonial works
  • Create custom testimonial works
  • Update preserves type and display order
  • Delete normalizes remaining orders
  • Toggle active status works
  • Reorder updates surrounding items
  • Avatar upload to Convex storage works
  • Public queries return active only
  • Featured filter works correctly
  • Type filter works correctly

🎯 Best Practices

  1. Mix Types: Combine tweets and custom testimonials for variety
  2. Feature Strategically: Highlight testimonials from notable customers
  3. Update Regularly: Keep testimonials fresh and current
  4. Verify Permissions: Always get permission before using customer testimonials
  5. Use High-Quality Images: Upload clear, professional-looking avatars
  6. Order Thoughtfully: Put strongest testimonials first

← Previous: FAQs | Next: Newsletter →

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro