โ† 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/posts

Need Help?

Check our webhook documentation for technical details, or contact support for custom integrations.