diff --git a/website/src/app/(v2)/(marketing)/rivet-vs-cloudflare-workers/page.tsx b/website/src/app/(v2)/(marketing)/rivet-vs-cloudflare-workers/page.tsx
index f3717ac054..b939d66bf0 100644
--- a/website/src/app/(v2)/(marketing)/rivet-vs-cloudflare-workers/page.tsx
+++ b/website/src/app/(v2)/(marketing)/rivet-vs-cloudflare-workers/page.tsx
@@ -848,7 +848,7 @@ const ComparisonTable = () => {
{group.features.map((feature, featureIndex) => (
{feature.feature}
diff --git a/website/src/app/(v2)/(marketing)/solutions/game-servers/page.tsx b/website/src/app/(v2)/(marketing)/solutions/game-servers/page.tsx
index 935fbfed0a..e838e267f3 100644
--- a/website/src/app/(v2)/(marketing)/solutions/game-servers/page.tsx
+++ b/website/src/app/(v2)/(marketing)/solutions/game-servers/page.tsx
@@ -73,7 +73,7 @@ const Badge = ({ text, color = "red" }) => {
const CodeBlock = ({ code, fileName = "match.ts" }) => {
return (
-
+
diff --git a/website/src/app/(v2)/(marketing)/templates/TemplatesPageClient.tsx b/website/src/app/(v2)/(marketing)/templates/TemplatesPageClient.tsx
deleted file mode 100644
index 22e884cde9..0000000000
--- a/website/src/app/(v2)/(marketing)/templates/TemplatesPageClient.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-"use client";
-
-import { useState, useMemo } from "react";
-import { templates, type Technology, type Tag } from "@/data/templates/shared";
-import { TemplateCard } from "./components/TemplateCard";
-import { TemplatesSidebar } from "./components/TemplatesSidebar";
-import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
-import Fuse from "fuse.js";
-
-export default function TemplatesPageClient() {
- const [searchQuery, setSearchQuery] = useState("");
- const [selectedTags, setSelectedTags] = useState ([]);
- const [selectedTechnologies, setSelectedTechnologies] = useState([]);
-
- // Get unique tags and technologies from all templates
- const allTags = useMemo(() => {
- const tagsSet = new Set();
- templates.forEach((template) => {
- template.tags.forEach((tag) => tagsSet.add(tag));
- });
- return Array.from(tagsSet).sort();
- }, []);
-
- const allTechnologies = useMemo(() => {
- const techSet = new Set();
- templates.forEach((template) => {
- template.technologies.forEach((tech) => techSet.add(tech));
- });
- return Array.from(techSet).sort();
- }, []);
-
- // Configure Fuse.js for fuzzy searching
- const fuse = useMemo(() => {
- return new Fuse(templates, {
- keys: [
- { name: "displayName", weight: 2 },
- { name: "description", weight: 1.5 },
- { name: "tags", weight: 1 },
- { name: "technologies", weight: 1 },
- ],
- threshold: 0.4,
- includeScore: true,
- });
- }, []);
-
- // Filter templates based on search and selections
- const filteredTemplates = useMemo(() => {
- let results = templates;
-
- // Apply fuzzy search if there's a query
- if (searchQuery.trim() !== "") {
- const fuseResults = fuse.search(searchQuery);
- results = fuseResults.map((result) => result.item);
- }
-
- // Apply tag and technology filters
- results = results.filter((template) => {
- // Tags filter
- const matchesTags =
- selectedTags.length === 0 ||
- selectedTags.some((tag) => template.tags.includes(tag));
-
- // Technologies filter
- const matchesTechnologies =
- selectedTechnologies.length === 0 ||
- selectedTechnologies.some((tech) => template.technologies.includes(tech));
-
- return matchesTags && matchesTechnologies;
- });
-
- return results;
- }, [searchQuery, selectedTags, selectedTechnologies, fuse]);
-
- const handleTagToggle = (tag: Tag) => {
- setSelectedTags((prev) =>
- prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
- );
- };
-
- const handleTechnologyToggle = (tech: Technology) => {
- setSelectedTechnologies((prev) =>
- prev.includes(tech) ? prev.filter((t) => t !== tech) : [...prev, tech],
- );
- };
-
- return (
-
-
-
-
-
- Templates
-
-
- Explore RivetKit templates and examples to quickly start building
- with Rivet Actors
-
-
-
-
-
-
- setSearchQuery(e.target.value)}
- className="block w-full rounded-lg border border-white/20 bg-white/5 pl-10 pr-3 py-3 text-white placeholder:text-zinc-500 focus:border-[#FF4500] focus:outline-none focus:ring-1 focus:ring-[#FF4500] text-base"
- placeholder="Search templates..."
- />
-
-
-
-
-
-
-
-
- {/* Sidebar */}
-
-
- {/* Main Grid */}
-
- {filteredTemplates.length === 0 ? (
-
- No templates found matching your filters
-
- ) : (
-
- {filteredTemplates.map((template) => (
-
- ))}
-
- )}
-
-
-
-
- );
-}
diff --git a/website/src/app/(v2)/(marketing)/templates/[slug]/DeployDropdown.tsx b/website/src/app/(v2)/(marketing)/templates/[slug]/DeployDropdown.tsx
new file mode 100644
index 0000000000..ee2cba33e1
--- /dev/null
+++ b/website/src/app/(v2)/(marketing)/templates/[slug]/DeployDropdown.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { Icon, faChevronDown, faEllipsis } from "@rivet-gg/icons";
+import { deployOptions } from "@/data/deploy/shared";
+import Link from "next/link";
+
+export function DeployDropdown() {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [isOpen]);
+
+ const otherPlatforms = deployOptions.filter(
+ option => option.title !== "Vercel" && option.title !== "Railway"
+ );
+
+ return (
+
+
+
+ {isOpen && (
+
+
+ {otherPlatforms.map((option) => (
+ setIsOpen(false)}
+ >
+ {option.icon && }
+ {option.shortTitle || option.title}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/website/src/app/(v2)/(marketing)/templates/[slug]/TemplateDetailClient.tsx b/website/src/app/(v2)/(marketing)/templates/[slug]/TemplateDetailClient.tsx
deleted file mode 100644
index e0d09c20e5..0000000000
--- a/website/src/app/(v2)/(marketing)/templates/[slug]/TemplateDetailClient.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-"use client";
-
-import type { Template } from "@/data/templates/shared";
-import { templates, TECHNOLOGIES, TAGS } from "@/data/templates/shared";
-import { Markdown } from "@/components/Markdown";
-import { TemplateCard } from "../components/TemplateCard";
-import { Icon, faGithub } from "@rivet-gg/icons";
-import Link from "next/link";
-
-interface TemplateDetailClientProps {
- template: Template;
- readmeContent: string;
-}
-
-export default function TemplateDetailClient({
- template,
- readmeContent,
-}: TemplateDetailClientProps) {
- // Find related templates based on shared tags
- const relatedTemplates = templates
- .filter((t) => {
- // Exclude the current template
- if (t.name === template.name) return false;
-
- // Find templates with at least one shared tag
- return template.tags.some((tag) => t.tags.includes(tag));
- })
- .slice(0, 3);
-
- // If no related templates with shared tags, just show any 3 templates
- const displayedRelated =
- relatedTemplates.length > 0
- ? relatedTemplates
- : templates.filter((t) => t.name !== template.name).slice(0, 3);
-
- const githubUrl = `https://github.com/rivet-dev/rivetkit/tree/main/examples/${template.name}`;
-
- return (
-
- {/* Header with Image */}
-
-
- {/* Placeholder Image */}
-
-
- {/* Title and Description */}
-
-
- {template.displayName}
-
-
- {template.description}
-
-
-
-
-
- {/* Content Section */}
-
-
- {/* Left Column - README Content */}
-
-
- {/* Right Column - Sidebar */}
-
-
-
- {/* Related Templates Section */}
-
-
- Related Templates
-
-
- {displayedRelated.map((relatedTemplate) => (
-
- ))}
-
-
-
-
- );
-}
diff --git a/website/src/app/(v2)/(marketing)/templates/[slug]/page.tsx b/website/src/app/(v2)/(marketing)/templates/[slug]/page.tsx
index 7121047c1b..da341d1a7d 100644
--- a/website/src/app/(v2)/(marketing)/templates/[slug]/page.tsx
+++ b/website/src/app/(v2)/(marketing)/templates/[slug]/page.tsx
@@ -1,9 +1,16 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
-import { templates } from "@/data/templates/shared";
+import { templates, TECHNOLOGIES, TAGS, type Template } from "@/data/templates/shared";
+import { VanillaMarkdown } from "@/components/VanillaMarkdown";
+import { TemplateCard } from "../components/TemplateCard";
+import { Code } from "@/components/v2/Code";
+import { Icon, faGithub, faVercel, faRailway } from "@rivet-gg/icons";
+import Link from "next/link";
+import Image from "next/image";
+import { CodeBlock } from "@/components/CodeBlock";
import fs from "node:fs/promises";
import path from "node:path";
-import TemplateDetailClient from "./TemplateDetailClient";
+import { DeployDropdown } from "./DeployDropdown";
interface Props {
params: Promise<{ slug: string }>;
@@ -51,6 +58,31 @@ async function getReadmeContent(templateName: string): Promise {
}
}
+function getRelatedTemplates(template: Template): Template[] {
+ // Find related templates based on shared tags
+ const relatedTemplates = templates
+ .filter((t) => {
+ if (t.name === template.name) return false;
+ return template.tags.some((tag) => t.tags.includes(tag));
+ })
+ .slice(0, 3);
+
+ // If no related templates with shared tags, just show any 3 templates
+ return relatedTemplates.length > 0
+ ? relatedTemplates
+ : templates.filter((t) => t.name !== template.name).slice(0, 3);
+}
+
+function cleanReadmeContent(content: string): string {
+ return content
+ .replace(/^#\s+.+$/m, '') // Remove first heading
+ .replace(/^\n+/, '') // Remove leading newlines
+ .replace(/^.+?(?=\n\n|\n#)/s, '') // Remove first paragraph
+ .replace(/##\s+Getting Started[\s\S]*?(?=\n##|$)/m, '') // Remove Getting Started section
+ .replace(/##\s+License[\s\S]*$/m, '') // Remove License section
+ .trim();
+}
+
export default async function Page({ params }: Props) {
const { slug } = await params;
const template = templates.find((t) => t.name === slug);
@@ -60,8 +92,225 @@ export default async function Page({ params }: Props) {
}
const readmeContent = await getReadmeContent(template.name);
+ const cleanedReadmeContent = cleanReadmeContent(readmeContent);
+ const displayedRelated = getRelatedTemplates(template);
+ const githubUrl = `https://github.com/rivet-dev/rivet/tree/main/examples/${template.name}`;
+
+ // Strip markdown links from description
+ const description = template.description.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
+
+ // Construct Vercel deploy URL with demo card parameters
+ const vercelDeployUrl = new URL('https://vercel.com/new/clone');
+ vercelDeployUrl.searchParams.set('repository-url', `https://github.com/rivet-dev/rivet/tree/main/examples/${template.name}`);
+ vercelDeployUrl.searchParams.set('project-name', template.displayName);
+ vercelDeployUrl.searchParams.set('demo-title', template.displayName);
+ vercelDeployUrl.searchParams.set('demo-description', description);
+ vercelDeployUrl.searchParams.set('demo-image', `https://www.rivet.dev/examples/${template.name}/image.png`);
+ vercelDeployUrl.searchParams.set('demo-url', `https://www.rivet.dev/templates/${template.name}`);
return (
-
+
+ {/* Header with Image */}
+
+
+ {!template.noFrontend ? (
+
+ {/* Screenshot on top */}
+
+
+ {/* Linear gradient overlay - darker on bottom */}
+
+
+
+ {/* Top Shine Highlight */}
+
+ {/* Window Bar */}
+
+ {/* Content Area - Screenshot */}
+
+
+
+
+
+
+ {/* Text content overlapping bottom */}
+
+
+
+ {template.displayName}
+
+
+ {description}
+
+
+
+
+ ) : (
+
+
+ {template.displayName}
+
+
+ {description}
+
+
+ )}
+
+
+
+ {/* Content Section */}
+
+
+ {/* Left Column - README Content */}
+
+
+ {cleanedReadmeContent}
+
+
+
+ {/* Right Column - Sidebar */}
+
+
+
+ {/* Related Templates Section */}
+
+
+ Related Templates
+
+
+ {displayedRelated.map((relatedTemplate) => (
+
+ ))}
+
+
+
+
);
}
diff --git a/website/src/app/(v2)/(marketing)/templates/components/TemplateCard.tsx b/website/src/app/(v2)/(marketing)/templates/components/TemplateCard.tsx
index c08334b2c8..84d8641ecf 100644
--- a/website/src/app/(v2)/(marketing)/templates/components/TemplateCard.tsx
+++ b/website/src/app/(v2)/(marketing)/templates/components/TemplateCard.tsx
@@ -1,6 +1,7 @@
import type { Template } from "@/data/templates/shared";
-import { TECHNOLOGIES } from "@/data/templates/shared";
+import { Icon, faArrowRight, faCode } from "@rivet-gg/icons";
import Link from "next/link";
+import Image from "next/image";
import clsx from "clsx";
interface TemplateCardProps {
@@ -8,57 +9,73 @@ interface TemplateCardProps {
}
export function TemplateCard({ template }: TemplateCardProps) {
+ // Strip markdown links from description
+ const description = template.description.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
+
return (
- {/* Placeholder Image */}
-
-
{/* Content */}
-
+
{/* Title */}
-
- {template.displayName}
-
+
+
+ {template.displayName}
+
+
+
{/* Description */}
-
- {template.description}
+
+ {description}
+
- {/* Technologies */}
-
- {template.technologies.map((tech) => {
- const techInfo = TECHNOLOGIES.find((t) => t.name === tech);
- return (
-
- {techInfo?.displayName || tech}
-
- );
- })}
+ {/* Template Image - 16:9 aspect ratio matches screenshots (see frontend/packages/example-registry/scripts/build/screenshots.ts) */}
+
+
+ {/* Browser Title Bar */}
+
+ {/* Screenshot Content */}
+
+ {!template.noFrontend ? (
+
+ ) : (
+
+
+
+ )}
+
+ {/* Bottom gradient overlay - stays fixed while screenshot moves */}
+
diff --git a/website/src/app/(v2)/(marketing)/templates/components/TemplateCardWrapper.tsx b/website/src/app/(v2)/(marketing)/templates/components/TemplateCardWrapper.tsx
new file mode 100644
index 0000000000..db648a3170
--- /dev/null
+++ b/website/src/app/(v2)/(marketing)/templates/components/TemplateCardWrapper.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import type { Template } from "@/data/templates/shared";
+import type { ReactNode } from "react";
+import { useTemplatesFilter } from "./TemplatesFilterContext";
+
+interface TemplateCardWrapperProps {
+ template: Template;
+ children: ReactNode;
+}
+
+export function TemplateCardWrapper({ template, children }: TemplateCardWrapperProps) {
+ const { isTemplateVisible } = useTemplatesFilter();
+ const visible = isTemplateVisible(template);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/website/src/app/(v2)/(marketing)/templates/components/TemplateImage.tsx b/website/src/app/(v2)/(marketing)/templates/components/TemplateImage.tsx
new file mode 100644
index 0000000000..592b5a750e
--- /dev/null
+++ b/website/src/app/(v2)/(marketing)/templates/components/TemplateImage.tsx
@@ -0,0 +1,33 @@
+import type { Template } from "@/data/templates/shared";
+import Image from "next/image";
+
+interface TemplateImageProps {
+ template: Template;
+ priority?: boolean;
+ sizes?: string;
+}
+
+export function TemplateImage({
+ template,
+ priority = false,
+ sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw",
+}: TemplateImageProps) {
+ if (template.noFrontend) {
+ return (
+
+ No Frontend
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/website/src/app/(v2)/(marketing)/templates/components/TemplatesFilterContext.tsx b/website/src/app/(v2)/(marketing)/templates/components/TemplatesFilterContext.tsx
new file mode 100644
index 0000000000..789e68d1c3
--- /dev/null
+++ b/website/src/app/(v2)/(marketing)/templates/components/TemplatesFilterContext.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { createContext, useContext, useState, useMemo, type ReactNode } from "react";
+import type { Technology, Tag, Template } from "@/data/templates/shared";
+import Fuse from "fuse.js";
+
+interface TemplatesFilterContextValue {
+ searchQuery: string;
+ setSearchQuery: (query: string) => void;
+ selectedTags: Tag[];
+ selectedTechnologies: Technology[];
+ handleTagToggle: (tag: Tag) => void;
+ handleTechnologyToggle: (tech: Technology) => void;
+ isTemplateVisible: (template: Template) => boolean;
+ hasActiveFilters: boolean;
+ clearAllFilters: () => void;
+}
+
+const TemplatesFilterContext = createContext (null);
+
+export function useTemplatesFilter() {
+ const context = useContext(TemplatesFilterContext);
+ if (!context) {
+ throw new Error("useTemplatesFilter must be used within TemplatesFilterProvider");
+ }
+ return context;
+}
+
+interface TemplatesFilterProviderProps {
+ children: ReactNode;
+ templates: Template[];
+}
+
+export function TemplatesFilterProvider({ children, templates }: TemplatesFilterProviderProps) {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [selectedTechnologies, setSelectedTechnologies] = useState([]);
+
+ // Configure Fuse.js for fuzzy searching
+ const fuse = useMemo(() => {
+ return new Fuse(templates, {
+ keys: [
+ { name: "displayName", weight: 2 },
+ { name: "description", weight: 1.5 },
+ { name: "tags", weight: 1 },
+ { name: "technologies", weight: 1 },
+ ],
+ threshold: 0.4,
+ includeScore: true,
+ });
+ }, [templates]);
+
+ // Compute which templates match the current filters
+ const visibleTemplateNames = useMemo(() => {
+ let results = templates;
+
+ // Apply fuzzy search if there's a query
+ if (searchQuery.trim() !== "") {
+ const fuseResults = fuse.search(searchQuery);
+ results = fuseResults.map((result) => result.item);
+ }
+
+ // Apply tag and technology filters
+ results = results.filter((template) => {
+ const matchesTags =
+ selectedTags.length === 0 ||
+ selectedTags.some((tag) => template.tags.includes(tag));
+
+ const matchesTechnologies =
+ selectedTechnologies.length === 0 ||
+ selectedTechnologies.some((tech) => template.technologies.includes(tech));
+
+ return matchesTags && matchesTechnologies;
+ });
+
+ return new Set(results.map((t) => t.name));
+ }, [searchQuery, selectedTags, selectedTechnologies, fuse, templates]);
+
+ const handleTagToggle = (tag: Tag) => {
+ setSelectedTags((prev) =>
+ prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
+ );
+ };
+
+ const handleTechnologyToggle = (tech: Technology) => {
+ setSelectedTechnologies((prev) =>
+ prev.includes(tech) ? prev.filter((t) => t !== tech) : [...prev, tech],
+ );
+ };
+
+ const isTemplateVisible = (template: Template) => {
+ return visibleTemplateNames.has(template.name);
+ };
+
+ const hasActiveFilters = selectedTags.length > 0 || selectedTechnologies.length > 0;
+
+ const clearAllFilters = () => {
+ setSelectedTags([]);
+ setSelectedTechnologies([]);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/website/src/app/(v2)/(marketing)/templates/components/TemplatesNoResults.tsx b/website/src/app/(v2)/(marketing)/templates/components/TemplatesNoResults.tsx
new file mode 100644
index 0000000000..f0190d0bbc
--- /dev/null
+++ b/website/src/app/(v2)/(marketing)/templates/components/TemplatesNoResults.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { templates } from "@/data/templates/shared";
+import { useTemplatesFilter } from "./TemplatesFilterContext";
+
+export function TemplatesNoResults() {
+ const { isTemplateVisible } = useTemplatesFilter();
+
+ // Check if any templates are visible
+ const hasVisibleTemplates = templates.some((template) => isTemplateVisible(template));
+
+ if (hasVisibleTemplates) {
+ return null;
+ }
+
+ return (
+
+ No templates found matching your filters
+
+ );
+}
diff --git a/website/src/app/(v2)/(marketing)/templates/components/TemplatesSearch.tsx b/website/src/app/(v2)/(marketing)/templates/components/TemplatesSearch.tsx
new file mode 100644
index 0000000000..52f6cfd78d
--- /dev/null
+++ b/website/src/app/(v2)/(marketing)/templates/components/TemplatesSearch.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
+import { useTemplatesFilter } from "./TemplatesFilterContext";
+
+export function TemplatesSearch() {
+ const { searchQuery, setSearchQuery } = useTemplatesFilter();
+
+ return (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="block w-full rounded-lg border border-white/20 bg-white/5 pl-10 pr-3 py-3 text-white placeholder:text-zinc-500 focus:border-white/50 focus:outline-none focus:ring-1 focus:ring-white/50 text-base"
+ placeholder="Search templates..."
+ />
+
+ );
+}
diff --git a/website/src/app/(v2)/(marketing)/templates/components/TemplatesSidebar.tsx b/website/src/app/(v2)/(marketing)/templates/components/TemplatesSidebar.tsx
index c3d76b4cb8..159e831f58 100644
--- a/website/src/app/(v2)/(marketing)/templates/components/TemplatesSidebar.tsx
+++ b/website/src/app/(v2)/(marketing)/templates/components/TemplatesSidebar.tsx
@@ -1,44 +1,64 @@
+"use client";
+
import type { Technology, Tag } from "@/data/templates/shared";
import { TECHNOLOGIES, TAGS } from "@/data/templates/shared";
+import { Icon, faCheck, faPlus } from "@rivet-gg/icons";
+import { useTemplatesFilter } from "./TemplatesFilterContext";
+import Link from "next/link";
interface TemplatesSidebarProps {
allTags: Tag[];
- selectedTags: Tag[];
- onTagToggle: (tag: Tag) => void;
allTechnologies: Technology[];
- selectedTechnologies: Technology[];
- onTechnologyToggle: (tech: Technology) => void;
}
export function TemplatesSidebar({
allTags,
- selectedTags,
- onTagToggle,
allTechnologies,
- selectedTechnologies,
- onTechnologyToggle,
}: TemplatesSidebarProps) {
+ const {
+ selectedTags,
+ selectedTechnologies,
+ handleTagToggle,
+ handleTechnologyToggle,
+ hasActiveFilters,
+ clearAllFilters,
+ } = useTemplatesFilter();
+
return (
|