
Cosmic AI
March 23, 2025
How to Build a Pinterest Clone with Svelte and Cosmic
In this tutorial, we'll build a Pinterest-style image discovery platform using Svelte for the frontend and Cosmic as our headless CMS backend. Our clone will feature a masonry grid layout, user collections, image uploads, and search functionality.
Tip: click the copy markdown button at the top of this page to copy the code to your clipboard and paste into your AI-powered code editor.
Project Setup
First, let's create a new Svelte project:
npm create svelte@latest pinterest-clone cd pinterest-clone npm install npm install @cosmicjs/sdk dotenv node-fetch
Setting Up Cosmic Backend
Create a new Cosmic bucket and use this improved seed script (saved as seed.js) to set up your content structure:
import { createBucketClient } from "@cosmicjs/sdk"; import dotenv from "dotenv"; import fetch from "node-fetch"; dotenv.config(); const BUCKET_SLUG = process.env.VITE_COSMIC_BUCKET_SLUG; const WRITE_KEY = process.env.VITE_COSMIC_WRITE_KEY; const READ_KEY = process.env.VITE_COSMIC_READ_KEY; if (!BUCKET_SLUG || !WRITE_KEY || !READ_KEY) { throw new Error("Missing required environment variables"); } const cosmic = createBucketClient({ bucketSlug: BUCKET_SLUG, writeKey: WRITE_KEY, readKey: READ_KEY, }); async function uploadMedia(url, filename) { const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const { media } = await cosmic.media.insertOne({ media: { originalname: filename, buffer, }, }); return media; } async function seedObjectTypes() { console.log("Setting up object types..."); try { // Check if object types already exist let object_types = []; try { const result = await cosmic.objectTypes.find(); object_types = result.object_types || []; } catch (error) { // If no object types exist, the API might return a 404 console.log("No object types found, will create new ones"); } const pinsTypeExists = object_types.some((type) => type.slug === "pins"); const boardsTypeExists = object_types.some( (type) => type.slug === "boards" ); if (pinsTypeExists && boardsTypeExists) { console.log("Object types already exist, skipping creation"); return; } // If we reach here, at least one of the types doesn't exist if (!pinsTypeExists) { // Create pins object type await cosmic.objectTypes.insertOne({ title: "Pins", slug: "pins", singular: "Pin", metafields: [ { type: "file", title: "Image", key: "image", required: true, }, { type: "textarea", title: "Description", key: "description", }, { type: "text", title: "Tags", key: "tags", }, { type: "text", title: "Link", key: "link", }, ], }); console.log("Created pins object type"); } else { console.log("Pins object type already exists"); } if (!boardsTypeExists) { // Create boards object type await cosmic.objectTypes.insertOne({ title: "Boards", slug: "boards", singular: "Board", metafields: [ { type: "textarea", title: "Description", key: "description", }, { type: "file", title: "Cover Image", key: "cover_image", }, { type: "objects", title: "Pins", key: "pins", object_type: "pins", }, ], }); console.log("Created boards object type"); } else { console.log("Boards object type already exists"); } console.log("Object types setup completed!"); } catch (error) { console.error("Error setting up object types:", error); throw error; } } async function seedContent() { console.log("Setting up content..."); try { // Check if pins already exist let existingPins = []; try { const result = await cosmic.objects.find({ type: "pins", }); existingPins = result.objects || []; } catch (error) { // If no objects exist, the API might return a 404 console.log("No pins found, will create new ones"); } if (existingPins.length > 0) { console.log( `Found ${existingPins.length} existing pins, skipping pin creation` ); return; } // Upload sample images console.log("Uploading first image..."); const pinImage1 = await uploadMedia( "https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f?w=500", "camera.jpg" ); console.log("First image uploaded:", pinImage1.name); console.log("Uploading second image..."); const pinImage2 = await uploadMedia( "https://images.unsplash.com/photo-1520839090488-4a6c211e2f94?w=500", "mountain.jpg" ); console.log("Second image uploaded:", pinImage2.name); // Create pins console.log("Creating first pin..."); const { object: pin1 } = await cosmic.objects.insertOne({ title: "Vintage Camera", slug: "vintage-camera", type: "pins", metadata: { image: pinImage1.name, description: "Beautiful vintage film camera", tags: "photography, vintage, camera", link: "https://example.com/camera", }, }); console.log("First pin created:", pin1.id); console.log("Creating second pin..."); const { object: pin2 } = await cosmic.objects.insertOne({ title: "Mountain Sunset", slug: "mountain-sunset", type: "pins", metadata: { image: pinImage2.name, description: "Breathtaking mountain sunset view", tags: "nature, mountains, sunset", link: "https://example.com/mountains", }, }); console.log("Second pin created:", pin2.id); // Check if boards already exist let existingBoards = []; try { const result = await cosmic.objects.find({ type: "boards", }); existingBoards = result.objects || []; } catch (error) { // If no objects exist, the API might return a 404 console.log("No boards found, will create a new one"); } if (existingBoards.length === 0) { // Create a board with pins console.log("Creating board..."); await cosmic.objects.insertOne({ title: "Photography Inspiration", slug: "photography-inspiration", type: "boards", metadata: { description: "Collection of inspiring photography", cover_image: pinImage1.name, pins: [pin1.id, pin2.id], }, }); console.log("Board created successfully"); } else { console.log( `Found ${existingBoards.length} existing boards, skipping board creation` ); } console.log("Content setup completed!"); } catch (error) { console.error("Error setting up content:", error); throw error; } } async function seed() { try { console.log("Starting seed process..."); await seedObjectTypes(); await seedContent(); console.log("✅ Cosmic bucket has been seeded with Pinterest clone data!"); } catch (error) { console.error("Error seeding data:", error); process.exit(1); } } seed();
Run the seed script with:
node seed.js
The script includes robust error handling to:
- Check if object types already exist before creating them
- Check if content already exists before creating duplicates
- Handle Cosmic API responses correctly
- Format metafields according to the Cosmic API requirements
Environment Setup
Create a .env file in your project root with your Cosmic credentials:
VITE_COSMIC_BUCKET_SLUG=your_bucket_slug VITE_COSMIC_READ_KEY=your_read_key VITE_COSMIC_WRITE_KEY=your_write_key
Building the Masonry Grid
Create a PinGrid.svelte component for the masonry layout:
<script> import { onMount } from 'svelte'; import { createBucketClient } from '@cosmicjs/sdk'; import PinCard from './PinCard.svelte'; let pins = []; let loading = true; const cosmic = createBucketClient({ bucketSlug: import.meta.env.VITE_COSMIC_BUCKET_SLUG, readKey: import.meta.env.VITE_COSMIC_READ_KEY }); onMount(async () => { try { const { objects } = await cosmic.objects.find({ type: 'pins' }) .props(['title', 'slug', 'metadata']) .depth(0); pins = objects; } catch (error) { console.error('Error fetching pins:', error); } finally { loading = false; } }); </script> <div class="masonry-grid"> {#if loading} <div class="loading">Loading pins...</div> {:else} {#each pins as pin} <PinCard {pin} /> {/each} {/if} </div> <style> .masonry-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-auto-rows: 10px; grid-gap: 16px; } .loading { grid-column: 1 / -1; text-align: center; padding: 2rem; } </style>
Creating the Pin Card Component
Create a PinCard.svelte component:
<script> export let pin; function calculateSpan() { // Calculate grid row span based on image aspect ratio return Math.ceil(((pin.metadata.image_height || 300) / (pin.metadata.image_width || 250)) * 25); } // Parse tags from comma-separated string to array $: tags = pin.metadata.tags ? pin.metadata.tags.split(',').map(tag => tag.trim()) : []; </script> <div class="pin-card" style="grid-row-end: span {calculateSpan()};"> <a href="/pin/{pin.slug}"> <img src={pin.metadata.image.imgix_url + '?w=500'} alt={pin.title} loading="lazy" /> <div class="pin-content"> <h3>{pin.title}</h3> <p>{pin.metadata.description}</p> <div class="tags"> {#each tags as tag} <span class="tag">{tag}</span> {/each} </div> </div> </a> </div> <style> .pin-card { break-inside: avoid; border-radius: 16px; overflow: hidden; position: relative; transition: transform 0.2s; } .pin-card:hover { transform: scale(1.02); } img { width: 100%; display: block; object-fit: cover; } .pin-content { padding: 12px; background: rgba(255, 255, 255, 0.9); } .tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; } .tag { background: #e9e9e9; padding: 4px 8px; border-radius: 16px; font-size: 12px; } </style>
Implementing Search Functionality
Create a SearchBar.svelte component:
<script> import { createEventDispatcher } from 'svelte'; import { createBucketClient } from '@cosmicjs/sdk'; const dispatch = createEventDispatcher(); let searchQuery = ''; let searching = false; const cosmic = createBucketClient({ bucketSlug: import.meta.env.VITE_COSMIC_BUCKET_SLUG, readKey: import.meta.env.VITE_COSMIC_READ_KEY }); async function handleSearch() { if (!searchQuery.trim()) return; searching = true; try { const { objects } = await cosmic.objects.find({ type: 'pins', $or: [ { title: { $regex: searchQuery, $options: 'i' } }, { 'metadata.description': { $regex: searchQuery, $options: 'i' } }, { 'metadata.tags': { $regex: searchQuery, $options: 'i' } } ] }) .props(['title', 'slug', 'metadata']) .depth(0); dispatch('results', { pins: objects }); } catch (error) { console.error('Search error:', error); } finally { searching = false; } } </script> <div class="search-container"> <input type="text" placeholder="Search pins..." bind:value={searchQuery} on:keyup={e => e.key === 'Enter' && handleSearch()} /> <button on:click={handleSearch} disabled={searching}> {searching ? 'Searching...' : 'Search'} </button> </div> <style> .search-container { display: flex; max-width: 600px; margin: 1rem auto; } input { flex: 1; padding: 12px 16px; border-radius: 24px 0 0 24px; border: 1px solid #ddd; } button { padding: 12px 24px; border-radius: 0 24px 24px 0; background: #e60023; color: white; border: none; cursor: pointer; } </style>
Creating a Pin Detail Page
Create a routes/pin/[slug]/+page.svelte file:
<script> import { onMount } from 'svelte'; import { page } from '$app/stores'; import { createBucketClient } from '@cosmicjs/sdk'; let pin = null; let loading = true; let error = null; const cosmic = createBucketClient({ bucketSlug: import.meta.env.VITE_COSMIC_BUCKET_SLUG, readKey: import.meta.env.VITE_COSMIC_READ_KEY }); onMount(async () => { try { const { object } = await cosmic.objects.findOne({ slug: $page.params.slug, type: 'pins' }) .props(['title', 'slug', 'metadata']) .depth(0); pin = object; } catch (err) { error = 'Pin not found'; console.error(err); } finally { loading = false; } }); // Parse tags from comma-separated string to array $: tags = pin?.metadata?.tags ? pin.metadata.tags.split(',').map(tag => tag.trim()) : []; </script> <main> <a href="/" class="back-button">← Back to Home</a> {#if loading} <div class="loading">Loading pin...</div> {:else if error} <div class="error">{error}</div> {:else} <div class="pin-detail"> <div class="image-container"> <img src={pin.metadata.image.imgix_url + '?w=800'} alt={pin.title} /> </div> <div class="pin-info"> <h1>{pin.title}</h1> <p class="description">{pin.metadata.description}</p> {#if pin.metadata.link} <a href={pin.metadata.link} target="_blank" rel="noopener noreferrer" class="source-link"> Visit Source </a> {/if} <div class="tags"> {#each tags as tag} <span class="tag">{tag}</span> {/each} </div> </div> </div> {/if} </main> <style> main { max-width: 1000px; margin: 0 auto; padding: 1rem; } .back-button { display: inline-block; margin-bottom: 1rem; text-decoration: none; color: #333; } .loading, .error { text-align: center; padding: 2rem; } .pin-detail { display: grid; grid-template-columns: 1fr; gap: 2rem; } @media (min-width: 768px) { .pin-detail { grid-template-columns: 1fr 1fr; } } .image-container { border-radius: 16px; overflow: hidden; } img { width: 100%; display: block; border-radius: 16px; } .pin-info { padding: 1rem; } h1 { margin-bottom: 1rem; } .description { margin-bottom: 1.5rem; line-height: 1.5; } .source-link { display: inline-block; background: #e60023; color: white; padding: 0.75rem 1.5rem; border-radius: 24px; text-decoration: none; margin-bottom: 1.5rem; } .tags { display: flex; flex-wrap: wrap; gap: 8px; } .tag { background: #f0f0f0; padding: 6px 12px; border-radius: 16px; font-size: 14px; } </style>
Creating a Board Detail Page
Create a BoardDetail.svelte component:
<script> import { onMount } from 'svelte'; import { createBucketClient } from '@cosmicjs/sdk'; import PinCard from './PinCard.svelte'; export let boardSlug; let board = null; let loading = true; let error = null; const cosmic = createBucketClient({ bucketSlug: import.meta.env.VITE_COSMIC_BUCKET_SLUG, readKey: import.meta.env.VITE_COSMIC_READ_KEY }); onMount(async () => { try { const { object } = await cosmic.objects.findOne({ slug: boardSlug, type: 'boards' }) .props(['title', 'slug', 'metadata']) .depth(1); board = object; } catch (err) { error = 'Board not found'; console.error(err); } finally { loading = false; } }); </script> {#if loading} <div class="loading">Loading board...</div> {:else if error} <div class="error">{error}</div> {:else} <header class="board-header"> {#if board.metadata.cover_image} <img class="cover-image" src={board.metadata.cover_image.imgix_url + '?w=1200&h=300&fit=crop'} alt={board.title} /> {/if} <h1>{board.title}</h1> <p>{board.metadata.description}</p> </header> <div class="pins-grid"> {#each board.metadata.pins as pin} <PinCard pin={pin} /> {/each} </div> {/if} <style> .board-header { text-align: center; margin-bottom: 2rem; } .cover-image { width: 100%; height: 200px; object-fit: cover; border-radius: 8px; } .pins-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-auto-rows: 10px; grid-gap: 16px; } </style>
Creating a Main Page
Create a routes/+page.svelte file that uses our components:
<script> import PinGrid from '$lib/components/PinGrid.svelte'; import SearchBar from '$lib/components/SearchBar.svelte'; import PinCard from '$lib/components/PinCard.svelte'; let searchResults = []; let isSearching = false; function handleSearchResults(event) { searchResults = event.detail.pins; isSearching = true; } function resetSearch() { isSearching = false; searchResults = []; } </script> <main> <header> <h1>Pinterest Clone</h1> <SearchBar on:results={handleSearchResults} /> {#if isSearching} <button class="reset-button" on:click={resetSearch}>Reset Search</button> {/if} </header> {#if isSearching} <div class="search-results"> <h2>Search Results ({searchResults.length})</h2> <div class="masonry-grid"> {#each searchResults as pin} <div class="pin-card-wrapper"> <PinCard {pin} /> </div> {/each} </div> </div> {:else} <PinGrid /> {/if} </main> <style> main { max-width: 1200px; margin: 0 auto; padding: 1rem; } header { text-align: center; margin-bottom: 2rem; } h1 { color: #e60023; margin-bottom: 1rem; } .reset-button { margin-top: 0.5rem; padding: 0.5rem 1rem; background: #f0f0f0; border: none; border-radius: 24px; cursor: pointer; } .search-results h2 { margin-bottom: 1rem; text-align: center; } .masonry-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-auto-rows: 10px; grid-gap: 16px; } </style>
Creating a Layout
Create a routes/+layout.svelte file for consistent styling:
<script> import { page } from '$app/stores'; </script> <div class="app"> <header class="app-header"> <div class="header-content"> <a href="/" class="logo">Pinterest Clone</a> <nav> <ul> <li><a href="/" class:active={$page.url.pathname === '/'}>Home</a></li> </ul> </nav> </div> </header> <main> <slot /> </main> <footer> <p>Built with Svelte and <a href="https://www.cosmicjs.com" target="_blank" rel="noopener noreferrer">Cosmic</a></p> </footer> </div> <style> :global(body) { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; background-color: #f9f9f9; color: #333; } :global(a) { color: #e60023; text-decoration: none; } .app { display: flex; flex-direction: column; min-height: 100vh; } .app-header { background-color: white; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); padding: 1rem 0; position: sticky; top: 0; z-index: 10; } .header-content { max-width: 1200px; margin: 0 auto; padding: 0 1rem; display: flex; justify-content: space-between; align-items: center; } .logo { font-weight: bold; font-size: 1.5rem; color: #e60023; } nav ul { display: flex; list-style: none; margin: 0; padding: 0; } nav a { display: block; padding: 0.5rem 1rem; color: #333; } nav a.active { color: #e60023; font-weight: bold; } main { flex: 1; } footer { text-align: center; padding: 2rem 0; background-color: white; border-top: 1px solid #eee; margin-top: 2rem; } </style>
Running the Application
After setting up all components and seeding your Cosmic bucket, you can run the application:
npm run dev
Conclusion
This tutorial provides a complete foundation for a Pinterest clone using Svelte and Cosmic. The application features:
- A masonry grid layout for visually appealing pin display
- Individual pin detail pages
- Board collections to organize pins
- Search functionality by title, description, or tags
- Responsive design for all device sizes
One of the key aspects of this implementation is the robust error handling in the seed script, which ensures your Cosmic bucket is set up correctly even if you run the script multiple times.
For tags, we're using a comma-separated string approach, which is then parsed into an array in the components. This simplifies the data structure while still providing good UX.
To enhance your app further, consider adding:
- User authentication
- Pin saving functionality
- Pin upload capability with Cosmic Intelligence for auto-generating descriptions
- Infinite scrolling for the masonry grid
- Related pins recommendations
The full source code for this project provides a solid starting point for building your own image discovery platform with an efficient headless CMS backend.







