Lovable Integration

    Paid Plan Feature

    Techne can push published blog posts directly into another Lovable website. The receiving site gets a full blog — index page, individual post pages, SEO meta tags, and structured data — all powered by a single webhook.

    Want the fastest path? Copy this prompt into your target Lovable project's chat. It creates the database table, webhook edge function, and blog pages automatically.

    Preview the prompt
    Add a blog to my website powered by Techne (techne.blog). Techne will push blog posts to my site via a webhook. Here is exactly what I need:
    
    ## 1. Database Migration
    
    Create a `blog_posts` table:
    
    CREATE TABLE public.blog_posts (
      id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
      external_id text UNIQUE NOT NULL,
      title text NOT NULL,
      slug text UNIQUE NOT NULL,
      body text,
      seo_description text,
      featured_image text,
      tags text[] DEFAULT '{}',
      author_name text,
      author_avatar text,
      author_slug text,
      published_at timestamptz,
      created_at timestamptz DEFAULT now(),
      updated_at timestamptz DEFAULT now()
    );
    
    ALTER TABLE public.blog_posts ENABLE ROW LEVEL SECURITY;
    
    -- Anyone can read published posts
    CREATE POLICY "Blog posts are publicly readable"
      ON public.blog_posts FOR SELECT
      USING (true);
    
    -- Only service role can write (via webhook)
    -- No INSERT/UPDATE/DELETE policies for anon/authenticated
    
    ## 2. Edge Function: `techne-webhook`
    
    Create `supabase/functions/techne-webhook/index.ts` with exactly this code:
    
    ```typescript
    import { serve } from "https://deno.land/[email protected]/http/server.ts";
    import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
    
    const corsHeaders = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-techne-signature, x-techne-event",
    };
    
    async function verifySignature(secret: string, signature: string, body: string): Promise<boolean> {
      const encoder = new TextEncoder();
      const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
      const sigBytes = new Uint8Array(signature.match(/.{2}/g)!.map(b => parseInt(b, 16)));
      return crypto.subtle.verify("HMAC", key, sigBytes, encoder.encode(body));
    }
    
    serve(async (req) => {
      if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
      if (req.method !== "POST") return new Response("Method not allowed", { status: 405, headers: corsHeaders });
    
      const secret = Deno.env.get("TECHNE_WEBHOOK_SECRET");
      if (!secret) return new Response(JSON.stringify({ success: false, error: "Webhook secret not configured" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
    
      const rawBody = await req.text();
      const signature = req.headers.get("x-techne-signature") || "";
      const valid = await verifySignature(secret, signature, rawBody);
      if (!valid) return new Response(JSON.stringify({ success: false, error: "Invalid signature" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
    
      const { event, post } = JSON.parse(rawBody);
      const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, { auth: { persistSession: false } });
    
      const siteUrl = (Deno.env.get("SITE_URL") || "").replace(/\/$/, "");
    
      if (event === "post.deleted") {
        await supabase.from("blog_posts").delete().eq("external_id", post.external_id);
        return new Response(JSON.stringify({ success: true }), { headers: { ...corsHeaders, "Content-Type": "application/json" } });
      }
    
      const { error } = await supabase.from("blog_posts").upsert({
        external_id: post.external_id,
        title: post.title,
        slug: post.slug,
        body: post.body || "",
        seo_description: post.seo_description || "",
        featured_image: post.featured_image || "",
        tags: post.tags || [],
        author_name: post.author_name || "",
        author_avatar: post.author_avatar || null,
        author_slug: post.author_slug || null,
        published_at: post.published_at,
        updated_at: new Date().toISOString(),
      }, { onConflict: "external_id" });
    
      if (error) return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
    
      const liveUrl = siteUrl ? `${siteUrl}/blog/${post.slug}` : `/blog/${post.slug}`;
    
      return new Response(JSON.stringify({ success: true, live_url: liveUrl }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } });
    });
    ```
    
    Also add this to your `supabase/config.toml`:
    
    ```toml
    [functions.techne-webhook]
    verify_jwt = false
    ```
    
    ## 3. Blog Index Page at `/blog`
    
    - Fetch all posts from `blog_posts` ordered by `published_at DESC`
    - Display in a responsive grid (1 col mobile, 2 cols tablet, 3 cols desktop)
    - Each card shows: featured image, title, author name, published date, tags, and a truncated SEO description
    - Link each card to `/blog/<slug>`
    - Add SEO meta: title "Blog | <Site Name>", description about the blog
    
    ## 4. Blog Post Page at `/blog/:slug`
    
    - Fetch the post by slug from `blog_posts`
    - Show 404 if not found
    - Render: featured image (full width), title as H1, author byline with avatar, published date, tags as badges, and body as `dangerouslySetInnerHTML`
    - Add full SEO meta: og:title, og:description, og:image, og:type=article, article:published_time, og:url
    - Add Twitter Card meta: twitter:card=summary_large_image, twitter:title, twitter:description, twitter:image
    - Add JSON-LD Article structured data
    - Clean typography with max-width prose container
    
    ## 5. Add routes to App.tsx
    
    Add these routes BEFORE any catch-all route:
    - `/blog` → BlogIndex page
    - `/blog/:slug` → BlogPost page
    
    ## 6. Required Secrets
    
    After deploying, add these secrets in your Lovable project settings:
    - `TECHNE_WEBHOOK_SECRET` — the shared HMAC secret (generate with `openssl rand -hex 32`)
    - `SITE_URL` — your canonical domain (e.g. `https://example.com`) with NO trailing slash
    
    ## Important Notes
    
    - The webhook secret will be added as a backend secret named `TECHNE_WEBHOOK_SECRET`
    - The blog should match your site's existing design system (use your existing Tailwind theme tokens)
    - No authentication needed for reading blog posts — they're public
    - The edge function uses `verify_jwt = false` since Techne authenticates via HMAC signature
    - Do NOT modify the edge function code — use it exactly as provided above

    Prompt builder

    Toggle the features your target Lovable site should support. We'll append matching instructions to the base setup prompt.

    Preview generated prompt
    Add a blog to my website powered by Techne (techne.blog). Techne will push blog posts to my site via a webhook. Here is exactly what I need:
    
    ## 1. Database Migration
    
    Create a `blog_posts` table:
    
    CREATE TABLE public.blog_posts (
      id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
      external_id text UNIQUE NOT NULL,
      title text NOT NULL,
      slug text UNIQUE NOT NULL,
      body text,
      seo_description text,
      featured_image text,
      tags text[] DEFAULT '{}',
      author_name text,
      author_avatar text,
      author_slug text,
      custom_fields jsonb DEFAULT '{}'::jsonb,
      published_at timestamptz,
      created_at timestamptz DEFAULT now(),
      updated_at timestamptz DEFAULT now()
    );
    
    ALTER TABLE public.blog_posts ENABLE ROW LEVEL SECURITY;
    CREATE POLICY "Blog posts are publicly readable" ON public.blog_posts FOR SELECT USING (true);
    
    ## 2. Edge Function: `techne-webhook`
    
    Create the `techne-webhook` edge function (HMAC-verified). Upsert posts by `external_id`, including the `custom_fields` payload as a jsonb column. Set `verify_jwt = false` for this function.
    
    ## 3. Blog pages at `/blog` and `/blog/:slug`
    
    - Index: list posts ordered by `published_at DESC` with featured image, title, author, date, tags.
    - Detail: render featured image, H1 title, byline, tags, and body via `dangerouslySetInnerHTML`.
    - Add proper SEO meta + JSON-LD Article structured data.
    
    ## 4. Required Secrets
    
    - `TECHNE_WEBHOOK_SECRET` — shared HMAC secret
    - `SITE_URL` — your canonical domain (no trailing slash)
    
    
    ## Media Embeds
    
    On the blog detail page, after the post body, render media players for these `custom_fields` keys when present:
    - `youtube_url` → embed via `https://www.youtube.com/embed/<videoId>` in a responsive 16:9 iframe.
    - `spotify_url` → embed via `https://open.spotify.com/embed/<type>/<id>` (handles episode, show, track, playlist, album).
    
    Use `<iframe loading="lazy" allowfullscreen>` and a wrapper with `aspect-ratio: 16 / 9` for YouTube and a fixed 232px height for Spotify. Sanitize all user-provided HTML in `body` with DOMPurify and allow these iframe sources: `youtube.com`, `youtube-nocookie.com`, `open.spotify.com`.
    
    ## Custom Fields
    
    The webhook payload includes a `custom_fields` object (jsonb). Render any non-empty fields below the post body in a small "Resources" section as labeled key/value pairs, auto-linking values that look like URLs. Skip keys prefixed with `wp_meta_` or matched by the media-embed renderer above.

    1. Getting Started

    Techne publishes content via webhook. When you publish, update, or delete a post in Techne, it sends an HTTP POST to an edge function on your Lovable site. The edge function verifies the request signature and upserts (or removes) the post in a blog_posts database table. Your React frontend then queries that table to render the blog.

    Because Lovable sites are single-page applications (React + Vite), crawlers like Googlebot, Facebook, Twitter, and LinkedIn need to see your post's meta tags when they crawl each URL. Lovable's hosting handles this automatically for published projects, so no extra prerendering service is required for a normal setup.

    Architecture Overview

    Techne (publish) ──POST──▶ techne-webhook (edge function)
                                    │
                             ┌──────┴──────┐
                             │  Supabase   │
                             │ blog_posts  │
                             └──────┬──────┘
                                    │
                         React frontend queries table
                                    │
                  ┌─────────────────┴─────────────────┐
                  ▼                                   ▼
             /blog (index)                     /blog/:slug

    2. Database Setup

    Create a blog_posts table in your Lovable project's database. The setup prompt handles this automatically, but here's the schema for reference:

    CREATE TABLE public.blog_posts (
      id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
      external_id text UNIQUE NOT NULL,
      title text NOT NULL,
      slug text UNIQUE NOT NULL,
      body text,
      seo_description text,
      featured_image text,
      tags text[] DEFAULT '{}',
      author_name text,
      author_avatar text,
      author_slug text,
      published_at timestamptz,
      created_at timestamptz DEFAULT now(),
      updated_at timestamptz DEFAULT now()
    );
    
    ALTER TABLE public.blog_posts ENABLE ROW LEVEL SECURITY;
    
    -- Anyone can read published posts
    CREATE POLICY "Blog posts are publicly readable"
      ON public.blog_posts FOR SELECT
      USING (true);
    
    -- Only the service role (webhook) can write.
    -- No INSERT/UPDATE/DELETE policies for anon/authenticated.
    ColumnTypePurpose
    external_idtext (unique)Techne's internal content ID — used as the upsert conflict key
    slugtext (unique)URL-safe slug for /blog/:slug
    bodytextFull HTML body of the post
    seo_descriptiontextMeta description for SEO and Open Graph
    featured_imagetextURL to the post's featured image
    tagstext[]Array of tag strings
    author_nametextDisplay name of the author
    author_avatartextURL to the author's avatar image
    author_slugtextAuthor's URL slug (for author pages)
    published_attimestamptzOriginal publish timestamp

    3. Webhook Setup

    The webhook edge function receives posts from Techne, verifies the HMAC-SHA256 signature, and upserts or deletes the post in your database.

    Edge Function Code

    Create supabase/functions/techne-webhook/index.ts with the exact code from the setup prompt above. The key points:

    • HMAC verification — The function reads the raw body, imports the shared secret as an HMAC key, and verifies the hex-encoded signature from thex-techne-signature header. If verification fails, it returns 401.
    • Upsert on conflict — Uses external_id as the conflict target so repeat publishes update the existing row rather than creating duplicates.
    • Delete handling — When event === "post.deleted", the function deletes the row by external_id.

    Config

    Add this to your supabase/config.toml so the function accepts unauthenticated requests (Techne authenticates via HMAC, not JWT):

    [functions.techne-webhook]
    verify_jwt = false

    Webhook URL

    Your webhook URL will be:

    https://<your-project-ref>.supabase.co/functions/v1/techne-webhook

    You can find your project ref in Lovable Cloud settings.

    Data Contract

    Techne sends an HTTP POST to your webhook with these headers:

    HeaderValue
    Content-Typeapplication/json
    X-Techne-SignatureHMAC-SHA256 hex digest of the body
    X-Techne-Eventpost.published, post.updated, or post.deleted

    The JSON body for post.published and post.updated:

    {
      "event": "post.published",
      "post": {
        "external_id": "uuid",
        "title": "string",
        "slug": "string",
        "body": "<html>",
        "seo_description": "string",
        "featured_image": "https://...",
        "tags": ["string"],
        "author_name": "string",
        "author_avatar": "https://...",
        "author_slug": "string",
        "published_at": "ISO 8601"
      }
    }

    The JSON body for post.deleted:

    {
      "event": "post.deleted",
      "post": {
        "external_id": "uuid",
        "slug": "string"
      }
    }

    Webhook Response

    Your webhook should return 200 with:

    { "success": true, "live_url": "https://your-site.com/blog/post-slug" }

    4. Frontend Pages

    Blog Index (/blog)

    • Query blog_posts ordered by published_at DESC
    • Display in a responsive grid (1 col mobile, 2 cols tablet, 3 cols desktop)
    • Each card shows: featured image, title, author name, published date, tags, and a truncated SEO description
    • Link each card to /blog/<slug>
    • Set page meta: <title>Blog | Your Site</title>

    Blog Post (/blog/:slug)

    • Query blog_posts by slug
    • Show a 404 page if not found
    • Render: featured image (full width), title as H1, author byline with avatar, published date, tags as badges
    • Render body via dangerouslySetInnerHTML inside a prose container

    Dynamic Meta Tags

    Use react-helmet-async (or a custom PageMeta component) to set per-post meta tags dynamically. This is critical for social sharing — without these tags, link previews will show your site's default metadata instead of the post content.

    // Example PageMeta component usage
    <Helmet>
      <title>{post.title} | Your Site</title>
      <meta name="description" content={post.seo_description} />
    
      {/* Open Graph */}
      <meta property="og:title" content={post.title} />
      <meta property="og:description" content={post.seo_description} />
      <meta property="og:image" content={post.featured_image} />
      <meta property="og:url" content={`https://your-site.com/blog/${post.slug}`} />
      <meta property="og:type" content="article" />
      <meta property="article:published_time" content={post.published_at} />
    
      {/* Twitter Card */}
      <meta name="twitter:card" content="summary_large_image" />
      <meta name="twitter:title" content={post.title} />
      <meta name="twitter:description" content={post.seo_description} />
      <meta name="twitter:image" content={post.featured_image} />
    </Helmet>

    JSON-LD Structured Data

    Add Article structured data for better search engine indexing:

    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "Article",
      "headline": "Post Title",
      "description": "SEO description",
      "image": "https://...",
      "datePublished": "2025-01-01T00:00:00Z",
      "author": {
        "@type": "Person",
        "name": "Author Name"
      }
    }
    </script>

    5. SEO: Dynamic Sitemap

    Static sitemaps (built at deploy time) won't include new blog posts added via webhook. For SEO best practices, generate your sitemap dynamically.

    Approach

    1. Create a generate-sitemap edge function that queries the blog_posts table for all post slugs and updated_at timestamps, combines them with your hardcoded static routes (home, about, contact, etc.), and generates valid XML.
    2. Upload to storage — Write the generated sitemap.xml to a public storage bucket so it's accessible at a stable URL.
    3. Cloudflare rewrite — Add a URL rewrite rule pointing/sitemap.xml to the storage bucket's public URL.
    4. Trigger from webhook — Call the generate-sitemap function after every post change so the sitemap stays up to date.
    5. Remove static sitemap — Delete any public/sitemap.xml from your repo to avoid serving stale data.

    Recommended Priorities

    PagePriorityChange Frequency
    Homepage1.0weekly
    Blog index0.8daily
    Blog posts0.6monthly
    Static pages0.5monthly

    Example Sitemap Entry

    <url>
      <loc>https://your-site.com/blog/my-post-slug</loc>
      <lastmod>2025-06-15T12:00:00Z</lastmod>
      <changefreq>monthly</changefreq>
      <priority>0.6</priority>
    </url>

    6. Required Secrets

    Add these as backend secrets in your Lovable project settings (not in code):

    Secret NameRequiredDescription
    TECHNE_WEBHOOK_SECRETYesShared HMAC secret for webhook signature verification. Generate with openssl rand -hex 32. Must match the value configured in Techne → Integrations → Lovable.
    SITE_URLRecommendedYour canonical domain (e.g. https://example.com). No trailing slash. Used for constructing live_url in webhook responses. If not set, the webhook will return relative URLs.

    7. Troubleshooting

    Webhook returns 401 "Invalid signature"

    • Ensure the TECHNE_WEBHOOK_SECRET in your Lovable project matches exactly what's configured in Techne → Integrations → Lovable.
    • The secret is case-sensitive. Copy-paste it directly — don't retype it.
    • If you regenerated the secret in Techne, update it in your Lovable project settings too.

    Webhook returns 500

    • Check the edge function logs in your Lovable project for the error message.
    • Common cause: the blog_posts table doesn't exist or has a different schema. Re-run the setup prompt or create the table manually with the SQL above.
    • If you see TypeError: Cannot read properties of undefined, the edge function code may have been modified. Re-paste the exact code from the setup prompt.

    Social shares show default/wrong OG image

    • Republish the post from Techne so the latest featured image and metadata are pushed to your site.
    • Use the Facebook Sharing Debugger or the LinkedIn Post Inspector to force a re-scrape of the URL. Social platforms cache previews aggressively, so the debugger is usually what clears it.
    • Confirm your blog post page renders the correct og:image, og:title, and og:description in the browser before blaming the crawler.

    www vs non-www mismatch

    • Ensure SITE_URL matches the canonical domain your users visit. If your site is at https://www.example.com, set SITE_URL to that — not https://example.com.
    • A mismatch causes the webhook to return the wrong live_url, which can point Techne's records at a URL that redirects or 404s.

    Trailing slash in SITE_URL

    • SITE_URL should not end with a slash. The webhook constructs URLs like {SITE_URL}/blog/{slug} — a trailing slash would result in double slashes (https://example.com//blog/...).
    • The webhook code strips trailing slashes automatically, but set it correctly to be safe.

    Posts not appearing on /blog

    • Verify the webhook returned { "success": true }. Check Techne's integration logs for the response.
    • Query the blog_posts table directly in your database to confirm the row was inserted.
    • Ensure your blog index page queries blog_posts without any auth filters that would hide rows from anonymous users.

    Publishing via API or MCP

    Include "lovable" in the platforms array when publishing via the REST API or MCP server:

    {
      "prompt": "Write about...",
      "platforms": ["techne", "lovable", "linkedin"]
    }

    Requirements

    • Paid Techne plan (Pro or Business)
    • Target site must be a Lovable project with Cloud enabled
    • The setup prompt must be run in the target project first