Lovable Integration
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 abovePrompt builder
Toggle the features your target Lovable site should support. We'll append matching instructions to the base setup prompt.
Render arbitrary jsonb fields on the post page (e.g. external links, sponsor info).
Expose a /sitemap.xml route that lists every published post.
Expose /feed.xml with the latest 20 posts.
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/:slug2. 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.| Column | Type | Purpose |
|---|---|---|
external_id | text (unique) | Techne's internal content ID — used as the upsert conflict key |
slug | text (unique) | URL-safe slug for /blog/:slug |
body | text | Full HTML body of the post |
seo_description | text | Meta description for SEO and Open Graph |
featured_image | text | URL to the post's featured image |
tags | text[] | Array of tag strings |
author_name | text | Display name of the author |
author_avatar | text | URL to the author's avatar image |
author_slug | text | Author's URL slug (for author pages) |
published_at | timestamptz | Original 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 the
x-techne-signatureheader. If verification fails, it returns 401. - Upsert on conflict — Uses
external_idas 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 byexternal_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 = falseWebhook URL
Your webhook URL will be:
https://<your-project-ref>.supabase.co/functions/v1/techne-webhookYou can find your project ref in Lovable Cloud settings.
Data Contract
Techne sends an HTTP POST to your webhook with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Techne-Signature | HMAC-SHA256 hex digest of the body |
X-Techne-Event | post.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_postsordered bypublished_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_postsby 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
bodyviadangerouslySetInnerHTMLinside aprosecontainer
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
- Create a
generate-sitemapedge function that queries theblog_poststable for all post slugs andupdated_attimestamps, combines them with your hardcoded static routes (home, about, contact, etc.), and generates valid XML. - Upload to storage — Write the generated
sitemap.xmlto a public storage bucket so it's accessible at a stable URL. - Cloudflare rewrite — Add a URL rewrite rule pointing
/sitemap.xmlto the storage bucket's public URL. - Trigger from webhook — Call the
generate-sitemapfunction after every post change so the sitemap stays up to date. - Remove static sitemap — Delete any
public/sitemap.xmlfrom your repo to avoid serving stale data.
Recommended Priorities
| Page | Priority | Change Frequency |
|---|---|---|
| Homepage | 1.0 | weekly |
| Blog index | 0.8 | daily |
| Blog posts | 0.6 | monthly |
| Static pages | 0.5 | monthly |
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 Name | Required | Description |
|---|---|---|
TECHNE_WEBHOOK_SECRET | Yes | Shared HMAC secret for webhook signature verification. Generate with openssl rand -hex 32. Must match the value configured in Techne → Integrations → Lovable. |
SITE_URL | Recommended | Your 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_SECRETin 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_poststable 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, andog:descriptionin the browser before blaming the crawler.
www vs non-www mismatch
- Ensure
SITE_URLmatches the canonical domain your users visit. If your site is athttps://www.example.com, setSITE_URLto that — nothttps://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_URLshould 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_poststable directly in your database to confirm the row was inserted. - Ensure your blog index page queries
blog_postswithout 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
