โ Back to Connections
Connect Nuxt.js to PostPenguin
Easy SetupFramework: Vue / Nuxt 3Time: 10 minutes
Nuxt 3 server routes make it easy to receive PostPenguin webhooks. This guide shows you how to create an API endpoint and store posts in your database.
๐ Quick Setup
1. Create Webhook Server Route
// server/api/webhooks/postpenguin.post.ts
import { createHmac, timingSafeEqual } from 'crypto'
interface PostPenguinPayload {
postPenguinId?: string
title: string
slug: string
html: string
meta_title?: string
meta_description?: string
featured_image?: string
tags?: string[]
}
// In-memory storage for demo (replace with your database)
const posts: Map<string, any> = new Map()
function verifySignature(payload: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(payload).digest('hex')
const received = signature.replace('sha256=', '')
return timingSafeEqual(Buffer.from(expected), Buffer.from(received))
}
export default defineEventHandler(async (event) => {
try {
// Get raw body for signature verification
const body = await readBody<PostPenguinPayload>(event)
const rawBody = JSON.stringify(body)
// Verify signature
const signature = getHeader(event, 'x-postpenguin-signature')
const secret = process.env.POSTPENGUIN_WEBHOOK_SECRET
if (secret && signature) {
if (!verifySignature(rawBody, signature, secret)) {
throw createError({ statusCode: 401, message: 'Invalid signature' })
}
}
// Validate required fields
if (!body.title || !body.slug || !body.html) {
throw createError({ statusCode: 400, message: 'Missing required fields' })
}
const postId = body.postPenguinId || `pp_${Date.now()}`
const now = new Date().toISOString()
// Create post object
const post = {
id: postId,
title: body.title,
slug: body.slug,
html: body.html,
metaTitle: body.meta_title || body.title,
metaDescription: body.meta_description || '',
featuredImage: body.featured_image || null,
tags: body.tags || [],
status: 'publish',
publishedAt: now,
createdAt: posts.has(postId) ? posts.get(postId).createdAt : now,
updatedAt: now,
}
// Save to storage (replace with your database)
const action = posts.has(postId) ? 'updated' : 'created'
posts.set(postId, post)
console.log(`โ
Post ${action}: ${post.title}`)
return {
success: true,
postId,
action,
}
} catch (error: any) {
console.error('Webhook error:', error)
if (error.statusCode) {
throw error
}
throw createError({ statusCode: 500, message: 'Internal server error' })
}
})2. Create Posts API
// server/api/posts/index.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const limit = parseInt(query.limit as string) || 10
const offset = parseInt(query.offset as string) || 0
// Replace with your database query
const allPosts = Array.from(posts.values())
.filter(p => p.status === 'publish')
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
const paginatedPosts = allPosts.slice(offset, offset + limit)
return {
posts: paginatedPosts,
pagination: {
total: allPosts.length,
limit,
offset,
}
}
})// server/api/posts/[slug].get.ts
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
// Replace with your database query
const post = Array.from(posts.values()).find(
p => p.slug === slug && p.status === 'publish'
)
if (!post) {
throw createError({ statusCode: 404, message: 'Post not found' })
}
return { post }
})๐พ With Prisma Database
Prisma Schema
// prisma/schema.prisma
model Post {
id String @id @default(cuid())
postpenguinId String @unique @map("postpenguin_id")
title String
slug String @unique
html String @db.Text
metaTitle String? @map("meta_title")
metaDescription String? @map("meta_description") @db.Text
featuredImage String? @map("featured_image")
tags Json @default("[]")
status String @default("publish")
publishedAt DateTime @default(now()) @map("published_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([status])
@@index([publishedAt])
@@map("posts")
}Webhook with Prisma
// server/api/webhooks/postpenguin.post.ts
import { PrismaClient } from '@prisma/client'
import { createHmac, timingSafeEqual } from 'crypto'
const prisma = new PrismaClient()
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Verify signature...
const postId = body.postPenguinId || `pp_${Date.now()}`
const post = await prisma.post.upsert({
where: { postpenguinId: postId },
update: {
title: body.title,
slug: body.slug,
html: body.html,
metaTitle: body.meta_title || body.title,
metaDescription: body.meta_description || '',
featuredImage: body.featured_image,
tags: body.tags || [],
publishedAt: new Date(),
},
create: {
postpenguinId: postId,
title: body.title,
slug: body.slug,
html: body.html,
metaTitle: body.meta_title || body.title,
metaDescription: body.meta_description || '',
featuredImage: body.featured_image,
tags: body.tags || [],
},
})
return { success: true, postId: post.id }
})๐จ Vue Component to Display Posts
<!-- pages/blog/index.vue -->
<script setup lang="ts">
const { data: postsData } = await useFetch('/api/posts')
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-4xl font-bold mb-8">Blog</h1>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="post in postsData?.posts"
:key="post.id"
:to="`/blog/${post.slug}`"
class="block p-6 bg-white rounded-lg shadow hover:shadow-lg transition"
>
<img
v-if="post.featuredImage"
:src="post.featuredImage"
:alt="post.title"
class="w-full h-48 object-cover rounded mb-4"
/>
<h2 class="text-xl font-semibold mb-2">{{ post.title }}</h2>
<p class="text-gray-600 text-sm">
{{ new Date(post.publishedAt).toLocaleDateString() }}
</p>
<div class="flex flex-wrap gap-2 mt-3">
<span
v-for="tag in post.tags"
:key="tag"
class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded"
>
{{ tag }}
</span>
</div>
</NuxtLink>
</div>
</div>
</template><!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const { data } = await useFetch(`/api/posts/${route.params.slug}`)
useHead({
title: data.value?.post?.metaTitle,
meta: [
{ name: 'description', content: data.value?.post?.metaDescription }
]
})
</script>
<template>
<article class="container mx-auto px-4 py-8 max-w-3xl">
<template v-if="data?.post">
<img
v-if="data.post.featuredImage"
:src="data.post.featuredImage"
:alt="data.post.title"
class="w-full h-64 object-cover rounded-lg mb-8"
/>
<h1 class="text-4xl font-bold mb-4">{{ data.post.title }}</h1>
<div class="flex items-center gap-4 text-gray-600 mb-8">
<time>{{ new Date(data.post.publishedAt).toLocaleDateString() }}</time>
<div class="flex gap-2">
<span
v-for="tag in data.post.tags"
:key="tag"
class="px-2 py-1 bg-gray-100 text-sm rounded"
>
{{ tag }}
</span>
</div>
</div>
<div class="prose prose-lg prose-gray max-w-none" v-html="data.post.html" />
</template>
</article>
</template>โ๏ธ Environment Variables
# .env
POSTPENGUIN_WEBHOOK_SECRET=your-secret-key-here
DATABASE_URL=postgresql://user:password@localhost:5432/database๐งช Testing
# Start dev server
npm run dev
# Test webhook
curl -X POST http://localhost:3000/api/webhooks/postpenguin \
-H "Content-Type: application/json" \
-d '{
"postPenguinId": "test_nuxt_123",
"title": "Test Nuxt Post",
"slug": "test-nuxt-post",
"html": "<p className="text-gray-700">This is a test post in Nuxt.</p>",
"tags": ["nuxt", "vue"]
}'
# Fetch posts
curl http://localhost:3000/api/postsNeed Help?
Check our webhook documentation for technical details, or contact support for custom integrations.