Suspense & Server Components Architecture
TinyKit Pro implements strategic use of React Suspense boundaries and Server Components to optimize both SEO performance and user experience. This document o...
Overview
TinyKit Pro implements strategic use of React Suspense boundaries and Server Components to optimize both SEO performance and user experience. This document outlines the patterns and best practices used throughout the codebase.
Key Principles
1. SEO-First Approach
Critical: SEO-critical content (homepage, landing pages) must be server-rendered with data available in the initial HTML.
✅ DO: Use Server Components with preloadQuery for SEO-critical sections
❌ DON'T: Use client-side useQuery for content that needs to be indexed
2. Parallel Data Fetching
Use Promise.all to fetch multiple data sources in parallel rather than sequential waterfalls.
3. Progressive Loading
Use nested Suspense boundaries to show content progressively rather than blocking entire pages.
Implementation Patterns
Pattern 1: Server Component with preloadQuery (SEO-Critical)
Use for: Landing page sections, public content, SEO-critical pages
// FAQSection.tsx - Server Component
import { preloadQuery, preloadedQueryResult } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
export async function FAQSection() {
const preloadedFAQs = await preloadQuery(api.faqs.public.queries.listActive);
const faqs = preloadedQueryResult(preloadedFAQs);
if (!faqs || faqs.length === 0) {
return null;
}
return (
<section id="faq">
{/* Render FAQ content - available in initial HTML */}
</section>
);
}Benefits:
- ✅ Content available to search engine crawlers
- ✅ Faster First Contentful Paint (FCP)
- ✅ Better Core Web Vitals scores
- ✅ Fully type-safe with Convex
Pattern 2: Server Component with Client Wrapper (Interactive Content)
Use for: SEO-critical content that needs client-side interactivity (animations, state management)
// TestimonialsSection.tsx - Server Component (data fetching)
import { preloadQuery, preloadedQueryResult } from "convex/nextjs";
import { TestimonialsSectionClient } from "./TestimonialsSectionClient";
export async function TestimonialsSection() {
// Fetch data in parallel
const [preloadedFeatured, preloadedRegular] = await Promise.all([
preloadQuery(api.testimonials.public.queries.listFeatured),
preloadQuery(api.testimonials.public.queries.listRegular),
]);
const featuredTestimonials = preloadedQueryResult(preloadedFeatured);
const regularTestimonials = preloadedQueryResult(preloadedRegular);
const allTestimonials = [
...(featuredTestimonials ?? []),
...(regularTestimonials ?? []),
];
// Pass data to client component for interactivity
return <TestimonialsSectionClient testimonials={allTestimonials} />;
}// TestimonialsSectionClient.tsx - Client Component (interactivity)
"use client";
import { motion } from "motion/react";
import type { Doc } from "@/convex/_generated/dataModel";
interface Props {
testimonials: Doc<"testimonials">[];
}
export function TestimonialsSectionClient({ testimonials }: Props) {
return (
<section>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
// Client-side animations work while data is in HTML
>
{testimonials.map((testimonial) => (
// Render testimonials
))}
</motion.div>
</section>
);
}Benefits:
- ✅ SEO: Content in initial HTML
- ✅ UX: Full client-side interactivity
- ✅ Performance: Data fetched on server
- ✅ Clean separation: Server (data) vs Client (UI)
Pattern 3: Suspense with Parallel Data Fetching (Dashboards)
Use for: Protected dashboards, admin pages, user-specific data
// page.tsx - Dashboard with client-side data fetching
"use client";
import { useQuery, Authenticated } from "convex/react";
import { api } from "@/convex/_generated/api";
import { useFeatureSettings } from "@/lib/features";
import { DashboardSkeleton } from "./DashboardSkeleton";
export default function AdminDashboard() {
return (
<Authenticated>
<AdminDashboardContent />
</Authenticated>
);
}
function AdminDashboardContent() {
const { enableOrganizations } = useFeatureSettings();
// Conditional query based on feature settings
const userStats = useQuery(api.users.private.queries.getUserStats);
const orgStats = useQuery(
api.orgs.private.queries.getOrgStats,
enableOrganizations ? {} : "skip",
);
const subscriptionStats = useQuery(
api.billing.private.queries.getSubscriptionStatsAdmin,
);
const isLoading =
userStats === undefined ||
(enableOrganizations && orgStats === undefined) ||
subscriptionStats === undefined;
if (isLoading) return <DashboardSkeleton />;
return (
<div>
<DashboardStatsCards
userStats={userStats}
orgStats={enableOrganizations ? (orgStats ?? null) : null}
subscriptionStats={subscriptionStats}
/>
{/* More dashboard content */}
</div>
);
}Benefits:
- ⚡ 20-40% faster perceived load time
- 🎨 Automatic loading states
- 📦 Parallel data fetching (no waterfalls)
- 🧹 Cleaner code (no manual
isLoadingchecks)
Before vs After:
// ❌ BEFORE: Sequential waterfall
const userStats = useQuery(api.users.private.queries.getUserStats);
const orgStats = useQuery(api.orgs.private.queries.getOrgStats); // Waits for userStats
const subscriptionStats = useQuery(
api.billing.private.queries.getSubscriptionStatsAdmin,
); // Waits for orgStats
// ✅ AFTER: Parallel fetching
const [userStatsPreloaded, orgStatsPreloaded, subscriptionStatsPreloaded] =
await Promise.all([
preloadQuery(api.users.private.queries.getUserStats),
preloadQuery(api.orgs.private.queries.getOrgStats),
preloadQuery(api.billing.private.queries.getSubscriptionStatsAdmin),
]);Pattern 4: Nested Suspense for Progressive Loading
Use for: Complex pages with multiple independent data sources
export default function ComplexDashboard() {
return (
<div>
{/* Header loads first */}
<Suspense fallback={<HeaderSkeleton />}>
<DashboardHeader />
</Suspense>
{/* Stats load independently */}
<div className="grid gap-6">
<Suspense fallback={<StatsSkeleton />}>
<StatsCards />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
</div>
);
}Benefits:
- 🎯 Progressive loading (show content as soon as ready)
- 🛡️ Better resilience (one failing query doesn't block others)
- ⚡ Faster perceived performance
Type Safety with preloadQuery
TinyKit Pro uses Convex's fully type-safe preloadQuery system:
// ✅ Correct: Fully type-safe
import { preloadQuery, preloadedQueryResult } from "convex/nextjs";
const preloaded = await preloadQuery(api.faqs.public.queries.listActive);
const faqs = preloadedQueryResult(preloaded);
// faqs is automatically typed as Doc<"faqs">[]
// ❌ Incorrect: Don't use type casting
const faqs = preloaded._valueJSON as unknown as Doc<"faqs">[];SEO Optimization Checklist
When implementing Server Components for SEO:
- Use
preloadQueryfor data fetching - Extract data with
preloadedQueryResult - Ensure content is rendered server-side (not in client component)
- Verify content appears in "View Source" (browser)
- Test with Lighthouse SEO audit
- Check Core Web Vitals (LCP, FCP, CLS)
Performance Optimization Checklist
When implementing Suspense boundaries:
- Use
Promise.allfor parallel data fetching - Create reusable skeleton components
- Place Suspense boundaries at appropriate granularity
- Avoid over-nesting (max 2-3 levels)
- Test loading states visually
- Measure performance improvements
Anti-Patterns to Avoid
❌ Don't: Suspense on Above-the-Fold SEO Content
// BAD - Homepage hero hidden from crawlers
export default function HomePage() {
return (
<Suspense fallback={<Spinner />}>
<HeroSection /> {/* SEO-critical! */}
<PricingSection /> {/* SEO-critical! */}
</Suspense>
);
}Solution: Use Server Components with preloadQuery instead.
❌ Don't: Over-nest Suspense Boundaries
// BAD - Too many nested boundaries
<Suspense>
<Suspense>
<Suspense>
<Suspense>
<Component />
</Suspense>
</Suspense>
</Suspense>
</Suspense>Solution: Max 2-3 levels of nesting for related content.
❌ Don't: Client-side useQuery for SEO Content
// BAD - Content not available to search engines
"use client";
export function FAQSection() {
const faqs = useQuery(api.faqs.public.queries.listActive);
// Content loads client-side, invisible to crawlers
}Solution: Convert to Server Component with preloadQuery.
Current Implementation
SEO-Optimized Pages
-
✅ Homepage FAQs (
/src/features/landing/sections/faq-section.tsx)- Server Component with preloadQuery
- Content in initial HTML
-
✅ Homepage Testimonials (
/src/features/landing/sections/testimonials-section.tsx)- Server Component with client wrapper
- Server fetches data, client handles animations
- Content in initial HTML
Performance-Optimized Pages
- ✅ Admin Dashboard (
/app/admin/page.tsx)- Suspense boundary with DashboardSkeleton
- Parallel data fetching (3 queries simultaneously)
- 20-40% faster perceived load time
Future Enhancements
Planned Improvements
- Organization Dashboard - Add nested Suspense for progressive loading
- Notifications Page - Simple Suspense wrapper
- Admin CRUD Pages - Bulk update 40+ pages with standard Suspense pattern
- Shared Skeleton Library - Create reusable skeleton components
Best Practices Summary
- SEO-Critical Content: Server Components with preloadQuery
- Interactive Content: Server Component (data) + Client Component (UI)
- Dashboards: Suspense + Parallel data fetching
- Complex Pages: Nested Suspense for progressive loading
- Type Safety: Always use
preloadedQueryResult, never cast - Performance: Measure with Lighthouse and Core Web Vitals