Framer + Supabase guide: Dynamic CMS View Counter

Date:

May 27, 2025

May 27, 2025

In modern web development, displaying engagement metrics like view counts on articles or CMS pages is a common requirement. This guide will walk you through creating a dynamic, customizable view counter component in Framer, powered by a Supabase backend (database and Edge Functions). We'll cover everything from database setup to creating a flexible Framer Code Component with UI controls for appearance.

What We'll Build:

  • A view counter that displays text (e.g., "Views: 123").

  • The counter will increment only once per user session for a given page to prevent inflation.

  • Data will be stored and managed in a Supabase PostgreSQL database.

  • Server-side logic will be handled by a Supabase Edge Function.

  • The Framer component will allow customization of prefix text, suffix text, font, font size, and text color via the Framer UI.

Step 1: Setting Up the Supabase Database

First, we need a table in our Supabase database to store the view counts for each page.

  1. Navigate to your Supabase project dashboard (app.supabase.com).

  2. Go to the SQL Editor (under the Database section in the left sidebar).

  3. Click New query and run the following SQL to create the page_views table:

    CREATE TABLE public.page_views (
      id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
      created_at timestamp with time zone DEFAULT now() NOT NULL, -- When the page record was first created
      last_viewed_at timestamp with time zone DEFAULT now() NOT NULL, -- When the count was last incremented
      page_id text NOT NULL UNIQUE, -- Unique identifier for the page (e.g., CMS slug)
      view_count bigint DEFAULT 1 NOT NULL -- The actual view count
    );
    
    -- Optional: Index for faster lookups by page_id
    CREATE INDEX idx_page_views_page_id ON public.page_views(page_id);
    
    -- Enable Row Level Security (RLS) - IMPORTANT!
    ALTER TABLE public.page_views ENABLE ROW LEVEL SECURITY;
    -- Note: Our Edge Function will use the service_role_key, which bypasses RLS for writes.
    -- If you needed direct client-side reads (not recommended for this counter),
    -- you would add a specific SELECT policy. For example:
    -- CREATE POLICY "Allow public read access to view counts" ON public.page_views
    -- FOR SELECT USING (true);

    This table stores a unique page_id (which we'll link to our CMS page slugs), the view_count, and timestamps for creation and last view. The UNIQUE constraint on page_id ensures we only have one row tracking views for each unique page.

Step 2: Creating the Supabase Edge Function

Edge Functions will handle the server-side logic for incrementing view counts and fetching them. This is crucial for security and reliable data operations.

  1. In your Supabase dashboard, go to Database > Functions (or simply "Functions" in the newer UI).

  2. Click Create a new function. Name it record-page-view.

  3. Shared CORS Helper (Optional but Recommended):
    Cross-Origin Resource Sharing (CORS) is a security mechanism. Since your Framer site will be on a different domain than your Supabase functions, you need to tell your function it's okay to accept requests from Framer.
    In the function editor sidebar, create a new folder named _shared. Inside this folder, create a new file named cors.ts:

    // supabase/functions/_shared/cors.ts
    export const corsHeaders = {
      'Access-Control-Allow-Origin': '*', // For development. For production, replace '*' with your Framer site's domain(s).
      'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', // Essential headers
      'Access-Control-Allow-Methods': 'POST, OPTIONS', // Specify allowed methods
    };
  4. record-page-view Function Code:
    Paste the following code into your record-page-view/index.ts file:

    // supabase/functions/record-page-view/index.ts
    import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
    import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.15.0' // Or your preferred version
    import { corsHeaders } from '../_shared/cors.ts'
    
    console.log(`Function "record-page-view" up and running!`);
    
    serve(async (req) => {
      // Handle CORS preflight requests
      if (req.method === 'OPTIONS') {
        return new Response('ok', { headers: corsHeaders });
      }
    
      try {
        const supabaseUrl = Deno.env.get('SUPABASE_URL');
        const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
    
        if (!supabaseUrl || !serviceRoleKey) {
          throw new Error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables.');
        }
    
        const supabaseAdmin = createClient(supabaseUrl, serviceRoleKey);
        const { pageId, action } = await req.json(); // Expecting 'pageId' and 'action'
    
        if (!pageId || !action) {
          return new Response(JSON.stringify({ error: 'Missing required fields: pageId, action' }), {
            headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400,
          });
        }
    
        let currentViewCount = 0;
    
        if (action === 'incrementAndGet') {
          // Use RPC for atomic upsert-and-increment (recommended)
          const { data: rpcData, error: rpcError } = await supabaseAdmin
            .rpc('increment_page_view', { p_page_id: pageId });
    
          if (rpcError) {
            console.error('RPC increment_page_view error (ensure SQL function exists, falling back):', rpcError.message);
            // Fallback logic (less ideal for high concurrency but functional)
            const { data: existing, error: selectErr } = await supabaseAdmin.from('page_views').select('view_count').eq('page_id', pageId).maybeSingle();
            if (selectErr) throw selectErr;
    
            if (existing) {
              const { data: updated, error: updErr } = await supabaseAdmin.from('page_views').update({ view_count: (existing.view_count || 0) + 1, last_viewed_at: new Date().toISOString() }).eq('page_id', pageId).select('view_count').single();
              if (updErr) throw updErr; currentViewCount = updated.view_count;
            } else {
              const { data: created, error: insErr } = await supabaseAdmin.from('page_views').insert({ page_id: pageId, view_count: 1, last_viewed_at: new Date().toISOString() }).select('view_count').single();
              if (insErr) throw insErr; currentViewCount = created.view_count;
            }
          } else {
            currentViewCount = rpcData; // RPC function returns the new count
          }
        } else if (action === 'getCount') {
          const { data: page, error: countError } = await supabaseAdmin.from('page_views').select('view_count').eq('page_id', pageId).maybeSingle();
          if (countError) throw countError;
          currentViewCount = page ? page.view_count : 0;
        } else {
          return new Response(JSON.stringify({ error: 'Invalid action specified' }), {
            headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400,
          });
        }
    
        return new Response(JSON.stringify({ viewCount: currentViewCount }), {
          headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200,
        });
      } catch (error) {
        console.error('View Counter Function Error:', error.message);
        return new Response(JSON.stringify({ error: error.message }), {
          headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500,
        });
      }
    });
  5. SQL Helper Function (Highly Recommended):
    For atomic and reliable increments (especially important if multiple users view a page around the same time), create the following PostgreSQL function using the Supabase SQL Editor:

    CREATE OR REPLACE FUNCTION increment_page_view(p_page_id TEXT)
    RETURNS BIGINT -- This function will return the new view_count
    LANGUAGE plpgsql
    AS $$
    DECLARE
      new_view_count BIGINT;
    BEGIN
      INSERT INTO public.page_views (page_id, view_count, last_viewed_at)
      VALUES (p_page_id, 1, NOW())
      ON CONFLICT (page_id) DO UPDATE
        SET view_count = public.page_views.view_count + 1,
            last_viewed_at = NOW()
      RETURNING public.page_views.view_count INTO new_view_count;
    
      RETURN new_view_count;
    END;
    $$;

    Run this query once in your SQL Editor. This creates a reusable function in your database that the Edge Function can call.

  6. Function Settings & Deployment:

    • In the settings for your record-page-view function within the Supabase dashboard, add your SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY as environment variables. You can find these in your Supabase Project Settings > API.

    • Under the function's settings, ensure JWT Verification is set to "No verification required" or "Anon key required". This is because our Framer component will call the function using the public anon key.

    • Click Deploy (or the equivalent button). Once deployed, Supabase will provide an Invocation URL for this function. Copy this URL; you'll need it for the Framer component.

Understanding Supabase Edge Functions in Our View Counter

Before we build the Framer component, let's quickly understand the role and benefits of using Supabase Edge Functions for our view counter.

What are Supabase Edge Functions?

Supabase Edge Functions are server-side TypeScript functions (running on Deno) that you can write and deploy directly within your Supabase project. They are "edge" functions because they are deployed globally on a distributed network, meaning they run physically close to your users, resulting in lower latency for the function execution itself.

Why Use an Edge Function Here?

For our view counter, using an Edge Function offers several key advantages over trying to interact with the database directly from the Framer component (client-side):

  1. Security:

    • Protecting Credentials: Our Edge Function uses the SUPABASE_SERVICE_ROLE_KEY. This key has full administrative access to your database and should never be exposed in client-side code (like a Framer component). The Edge Function acts as a secure intermediary. The Framer component only needs the public anon key to invoke the function.

    • Controlled Access: The Edge Function defines exactly what operations can be performed (incrementing a view, getting a count). You don't expose your entire database schema or arbitrary query capabilities to the client.

  2. Server-Side Logic:

    • Atomic Operations: Incrementing a counter reliably, especially with potential concurrent users, is best done server-side. Our increment_page_view SQL function, called by the Edge Function, ensures that view counts are updated correctly and atomically (as a single, indivisible operation), preventing race conditions where multiple increments might overwrite each other.

    • Data Validation & Transformation: While simple in this example, Edge Functions can perform complex data validation or transformations before writing to or reading from the database.

  3. Performance (Reduced Client Burden & Potential Latency Improvement):

    • Reduced Client-Side Load: Offloads database interaction logic from the user's browser.

    • Edge Proximity: While your database might be in one region, the Edge Function itself can run closer to the user. For data-fetching operations that don't require complex computations before hitting the DB, this means the initial network hop can be faster.

  4. Abstraction & Maintainability:

    • The Framer component interacts with a simple API endpoint (the Edge Function URL). It doesn't need to know the specifics of the database table structure or SQL queries. If you change your database schema or backend logic later, you might only need to update the Edge Function, potentially without any changes to the client-side Framer component.

How Our record-page-view Function Works:

  1. Invocation: The Framer component sends an HTTP POST request to the Edge Function's unique URL. This request includes:

    • The apikey header (your Supabase anon key) for basic authentication allowing the function to be called.

    • A JSON body containing the pageId (e.g., the CMS slug) and an action (either incrementAndGet or getCount).

  2. Authentication & Authorization (at the Gateway):

    • Supabase's gateway first checks the apikey header to ensure it's a valid key for your project.

    • If JWT Verification were enabled (it's not strictly for anon key calls), further checks would occur.

  3. Function Execution:

    • Environment Variables: The function securely retrieves SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY from its pre-configured environment variables.

    • Supabase Admin Client: It initializes a Supabase client instance using the service_role_key. This client has privileged access necessary to interact with the database according to the function's logic.

    • Processing the Request:

      • It parses the pageId and action from the request body.

      • If action is incrementAndGet: It calls the increment_page_view PostgreSQL function (via supabaseAdmin.rpc(...)). This SQL function handles the logic of either inserting a new page record with view_count = 1 or incrementing the view_count for an existing page.

      • If action is getCount: It directly queries the page_views table for the view_count associated with the given pageId.

  4. Sending the Response:

    • It constructs a JSON response containing the viewCount.

    • This response is sent back to the Framer component.

    • CORS headers (from _shared/cors.ts) are included to allow the Framer site (running on a different domain) to successfully receive and process this response.

In essence, the Edge Function acts as a secure and specialized API endpoint for managing page views, shielding the database and providing a clean interface for the client-side Framer component.

Step 3: Building the Framer Code Component (ViewCounter.tsx)

Now, let's create the text-only React component in Framer.

  1. In your Framer project, go to Assets > Code > New File. Name it ViewCounter.tsx.

  2. Paste the following code:

    import React, { useState, useEffect } from "react"
    import { addPropertyControls, ControlType, RenderTarget, Font } from "framer"
    
    // Local storage key prefix to avoid collisions
    const VIEWED_PAGE_KEY_PREFIX = "framerViewCounter_pageViewed_"
    
    export function ViewCounter(props) {
        const {
            pageId,
            supabaseUrl,
            supabaseAnonKey,
            edgeFunctionUrl,
            font,
            fontSize,
            textColor,
            prefixText,
            suffixText,
        } = props
    
        const [viewCount, setViewCount] = useState<number | null>(null)
        const [isLoading, setIsLoading] = useState<boolean>(true)
        const [error, setError] = useState<string | null>(null)
    
        useEffect(() => {
            // Prevent API calls in Framer canvas for better performance and to avoid errors
            if (RenderTarget.current() === RenderTarget.canvas) {
                setViewCount(123); // Display a placeholder count in the canvas
                setIsLoading(false);
                return;
            }
    
            // Ensure all necessary props are provided before making an API call
            if (!pageId || !edgeFunctionUrl || !supabaseUrl || !supabaseAnonKey) {
                console.warn("ViewCounter: Missing necessary props for API call.");
                setError("Config missing");
                setIsLoading(false);
                setViewCount(0); // Default to 0 if config is incomplete
                return;
            }
    
            const storageKey = `${VIEWED_PAGE_KEY_PREFIX}${pageId}`;
            const hasViewedInSession = localStorage.getItem(storageKey) === 'true';
            // Determine action: if already viewed in session, just get count, else increment.
            const action = hasViewedInSession ? 'getCount' : 'incrementAndGet';
    
            const fetchViewCount = async () => {
                setIsLoading(true);
                setError(null);
                try {
                    const response = await fetch(edgeFunctionUrl, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json',
                            'apikey': supabaseAnonKey, // Required for Supabase function invocation
                        },
                        body: JSON.stringify({ pageId, action }),
                    });
    
                    if (!response.ok) {
                        const errorData = await response.json().catch(() => ({})); // Try to parse error
                        throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
                    }
    
                    const data = await response.json();
                    setViewCount(data.viewCount ?? 0); // Update state with fetched count
    
                    // If the count was incremented, mark this page as viewed in this session
                    if (action === 'incrementAndGet' && !hasViewedInSession) {
                        localStorage.setItem(storageKey, 'true');
                    }
                } catch (err) {
                    console.error("Error fetching view count:", err.message);
                    setError(err.message); // Display error message
                    setViewCount(0); // Default to 0 on error
                } finally {
                    setIsLoading(false);
                }
            };
    
            fetchViewCount();
        }, [pageId, edgeFunctionUrl, supabaseUrl, supabaseAnonKey]); // Re-run effect if these props change
    
        const textStyle: React.CSSProperties = {
            display: "inline-block", // Ensures proper layout for a text element
            fontFamily: font?.fontFamily,
            fontWeight: font?.fontWeight,
            fontStyle: font?.fontStyle,
            fontSize: `${fontSize}px`,
            color: textColor,
            lineHeight: 1, // Can help with consistent vertical alignment
        };
    
        // Determine what content to display based on loading/error state
        const content = isLoading && RenderTarget.current() !== RenderTarget.canvas ? `...` : // Loading indicator
                        error ? `Error` : // Error indicator
                        `${viewCount !== null ? viewCount : "0"}`; // Actual count or 0
    
        return (
            <span style={textStyle}> {/* Using a span for inline text display */}
                {prefixText}
                {content}
                {suffixText}
            </span>
        );
    }
    
    // Define properties exposed in the Framer UI
    addPropertyControls(ViewCounter, {
        pageId: {
            type: ControlType.String,
            title: "Page ID",
            defaultValue: "cms-page-slug",
            description: "Connect to your CMS item's unique Slug field."
        },
        supabaseUrl: {
            type: ControlType.String,
            title: "Supabase URL",
            placeholder: "https://your-project.supabase.co"
        },
        supabaseAnonKey: {
            type: ControlType.String,
            title: "Supabase Anon Key",
            placeholder: "ey..."
        },
        edgeFunctionUrl: {
            type: ControlType.String,
            title: "View Function URL",
            placeholder: "https://.../functions/v1/record-page-view"
        },
        prefixText: {
            type: ControlType.String,
            title: "Prefix Text",
            defaultValue: "Views: ",
        },
        font: {
            type: ControlType.Font,
            title: "Font (Text)"
        },
        fontSize: {
            type: ControlType.Number,
            title: "Font Size (Text)",
            defaultValue: 16,
            min: 8,
            max: 72,
            unit: "px",
            step: 1,
            displayStepper: true,
        },
        textColor: {
            type: ControlType.Color,
            title: "Text Color",
            defaultValue: "#333333"
        },
        suffixText: {
            type: ControlType.String,
            title: "Suffix Text",
            defaultValue: "", // Default to empty for flexibility
        },
    });
    
    // Define default props for the component
    ViewCounter.defaultProps = {
        pageId: "default-page",
        supabaseUrl: "",
        supabaseAnonKey: "",
        edgeFunctionUrl: "",
        prefixText: "Views: ",
        fontSize: 16,
        textColor: "#333333",
        suffixText: "",
    };

    This component includes:

    • State management for viewCount, isLoading, and error.

    • An useEffect hook that intelligently fetches or increments the view count. It uses localStorage to track if a page has been viewed in the current browser session, preventing multiple increments on page refresh.

    • Clear property controls defined with addPropertyControls for all necessary configurations and visual styling

      (prefix/suffix text, font family/style/weight, font size, and text color).

    • Logic to display a placeholder ("123") in the Framer canvas to avoid live API calls during the design phase and provide visual feedback.

Step 4: Using the ViewCounter in Your Framer CMS

  1. After saving ViewCounter.tsx, it will appear in your Assets panel in Framer (under "Code").

  2. Drag your new ViewCounter component onto your CMS page template (this is the page that defines the layout for individual CMS items like blog posts or product pages).

  3. Select the ViewCounter instance on the canvas.

  4. In the Properties Panel on the right, you will see the custom controls we defined. Configure them as follows:

    • Page ID: This is the most critical field for unique tracking. Connect this field to the Slug field of your CMS collection (e.g., if your collection is "Blog Posts", you'd find the "Slug" variable under "Blog Posts -> Slug" when clicking the "+" icon next to the Page ID field).

    • Supabase URL: Enter your Supabase project URL (e.g., https://<your-project-ref>.supabase.co).

    • Supabase Anon Key: Enter your Supabase project's public anonymous key (the anon public key).

    • View Function URL: Paste the Invocation URL of your record-page-view Supabase Edge Function that you copied earlier.

    • Prefix Text: (Optional) Text to display before the count, like "Views: " or "Reads: ".

    • Font (Text): Use Framer's font picker to choose the font family, weight, and style.

    • Font Size (Text): Set the desired font size for the counter.

    • Text Color: Choose the color for the counter text.

    • Suffix Text: (Optional) Text to display after the count, like " views" or leave blank if your prefix already includes it.

How it Works - The Flow:

  1. A user navigates to a CMS page on your Framer site.

  2. The ViewCounter component mounts on that page.

  3. Client-Side Check: The component first checks the browser's localStorage for a key like framerViewCounter_pageViewed_THE-PAGE-SLUG.

  4. Determine Action:

    • If the key exists and is true, the page has already been "counted" in this session. The component will ask the Edge Function to just getCount.

    • If the key doesn't exist, this is a new view for the session. The component will ask the Edge Function to incrementAndGet.

  5. API Call: The component makes an HTTP POST request to your record-page-view Edge Function, sending the pageId and the determined action.

  6. Edge Function Processing:

    • The function receives the request.

    • Using the secure service_role_key, it interacts with your page_views Supabase table.

    • If incrementAndGet, it calls the increment_page_view SQL function (or the fallback). This either creates a new row for the page_id with view_count = 1 or increments the view_count for an existing page.

    • If getCount, it simply queries the current view_count.

    • The Edge Function returns the latest viewCount as JSON.

  7. Update UI & Local Storage:

    • The ViewCounter component receives the viewCount and updates its display.

    • If the view was incremented (incrementAndGet), the component sets the localStorage key (framerViewCounter_pageViewed_THE-PAGE-SLUG to true), so subsequent refreshes or revisits within the same session won't re-increment.

Further Enhancements & Considerations:

This is a very simple implementation of a view counter, it is made to show how you can implement supabase into your Framer workflow. Here are things you can do to enhance the view counter fi you want to proceed with using it:

  • More Sophisticated "Session" or "Unique User" Tracking: localStorage is tied to a specific browser and can be cleared. For more robust unique user tracking over longer periods or across devices, consider integrating Supabase Authentication (even anonymous auth) to assign persistent user IDs.

  • Error Handling: The current component shows a simple "Error" message. You could enhance this with more specific error displays or retry mechanisms.

  • Accessibility (A11y): For purely decorative counters, ensure they don't interfere with screen readers. If the count is important information, ensure it's clearly conveyed.

  • Debouncing/Throttling: For extremely high-traffic sites where even session-based counting might generate many initial hits, you could explore debouncing increments on the client-side before sending to the Edge Function, though the per-session logic already handles most common scenarios.

  • Data Privacy: Be mindful of any data privacy regulations (like GDPR) if you were to extend this to track more specific user data. The current IP-based session tracking is generally low-impact.