โ† Back to Connections

Connect Remix to PostPenguin

Easy SetupFramework: RemixTime: 10 minutes

Remix's resource routes make it easy to create webhook endpoints. This guide shows you how to receive PostPenguin posts and render them on your Remix site.

๐Ÿš€ Quick Setup

1. Create Webhook Resource Route

// app/routes/api.webhooks.postpenguin.ts
import { json, type ActionFunctionArgs } from '@remix-run/node'
import { createHmac, timingSafeEqual } from 'crypto'

// In-memory storage for demo (replace with database)
const posts = new Map<string, any>()

function verifySignature(payload: string, signature: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(payload).digest('hex')
  const received = signature.replace('sha256=', '')
  try {
    return timingSafeEqual(Buffer.from(expected), Buffer.from(received))
  } catch {
    return false
  }
}

export async function action({ request }: ActionFunctionArgs) {
  if (request.method !== 'POST') {
    return json({ error: 'Method not allowed' }, { status: 405 })
  }

  try {
    const body = await request.text()
    const data = JSON.parse(body)
    
    // Verify signature
    const signature = request.headers.get('x-postpenguin-signature')
    const secret = process.env.POSTPENGUIN_WEBHOOK_SECRET
    
    if (secret && signature) {
      if (!verifySignature(body, signature, secret)) {
        return json({ error: 'Invalid signature' }, { status: 401 })
      }
    }
    
    // Validate required fields
    if (!data.title || !data.slug || !data.html) {
      return json({ error: 'Missing required fields' }, { status: 400 })
    }
    
    const postId = data.postPenguinId || `pp_${Date.now()}`
    const now = new Date().toISOString()
    
    const post = {
      id: postId,
      title: data.title,
      slug: data.slug,
      html: data.html,
      metaTitle: data.meta_title || data.title,
      metaDescription: data.meta_description || '',
      featuredImage: data.featured_image || null,
      tags: data.tags || [],
      status: 'publish',
      publishedAt: now,
      createdAt: posts.has(postId) ? posts.get(postId).createdAt : now,
      updatedAt: now,
    }
    
    const action = posts.has(postId) ? 'updated' : 'created'
    posts.set(postId, post)
    
    console.log(`โœ… Post ${action}: ${post.title}`)
    
    return json({ success: true, postId, action })
    
  } catch (error) {
    console.error('Webhook error:', error)
    return json({ error: 'Internal server error' }, { status: 500 })
  }
}

// Export for other routes
export { posts }

2. Create Posts API Route

// app/routes/api.posts.ts
import { json, type LoaderFunctionArgs } from '@remix-run/node'
import { posts } from './api.webhooks.postpenguin'

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  const limit = parseInt(url.searchParams.get('limit') || '10')
  const offset = parseInt(url.searchParams.get('offset') || '0')
  
  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 json({
    posts: paginatedPosts,
    pagination: {
      total: allPosts.length,
      limit,
      offset
    }
  })
}
// app/routes/api.posts.$slug.ts
import { json, type LoaderFunctionArgs } from '@remix-run/node'
import { posts } from './api.webhooks.postpenguin'

export async function loader({ params }: LoaderFunctionArgs) {
  const post = Array.from(posts.values()).find(
    p => p.slug === params.slug && p.status === 'publish'
  )
  
  if (!post) {
    return json({ error: 'Post not found' }, { status: 404 })
  }
  
  return json({ post })
}

๐Ÿ’พ With Prisma Database

// app/routes/api.webhooks.postpenguin.ts
import { json, type ActionFunctionArgs } from '@remix-run/node'
import { prisma } from '~/lib/db.server'

export async function action({ request }: ActionFunctionArgs) {
  const data = await request.json()
  
  // Verify signature...
  
  const postId = data.postPenguinId || `pp_${Date.now()}`
  
  const post = await prisma.post.upsert({
    where: { postpenguinId: postId },
    update: {
      title: data.title,
      slug: data.slug,
      html: data.html,
      metaTitle: data.meta_title || data.title,
      metaDescription: data.meta_description || '',
      featuredImage: data.featured_image,
      tags: data.tags || [],
      publishedAt: new Date(),
    },
    create: {
      postpenguinId: postId,
      title: data.title,
      slug: data.slug,
      html: data.html,
      metaTitle: data.meta_title || data.title,
      metaDescription: data.meta_description || '',
      featuredImage: data.featured_image,
      tags: data.tags || [],
    },
  })
  
  return json({ success: true, postId: post.id })
}

๐ŸŽจ Blog Routes

Blog Index

// app/routes/blog._index.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData, Link } from '@remix-run/react'
import { prisma } from '~/lib/db.server'

export async function loader({ request }: LoaderFunctionArgs) {
  const posts = await prisma.post.findMany({
    where: { status: 'publish' },
    orderBy: { publishedAt: 'desc' },
    take: 10,
  })
  
  return json({ posts })
}

export default function BlogIndex() {
  const { posts } = useLoaderData<typeof loader>()
  
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>
      
      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts.map(post => (
          <Link
            key={post.id}
            to={`/blog/${post.slug}`}
            className="block p-6 bg-white rounded-lg shadow hover:shadow-lg transition"
          >
            {post.featuredImage && (
              <img
                src={post.featuredImage}
                alt={post.title}
                className="w-full h-48 object-cover rounded mb-4"
              />
            )}
            <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
            <p className="text-gray-600 text-sm">
              {new Date(post.publishedAt).toLocaleDateString()}
            </p>
            <div className="flex flex-wrap gap-2 mt-3">
              {post.tags?.map((tag: string) => (
                <span
                  key={tag}
                  className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded"
                >
                  {tag}
                </span>
              ))}
            </div>
          </Link>
        ))}
      </div>
    </div>
  )
}

Blog Post

// app/routes/blog.$slug.tsx
import { json, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { prisma } from '~/lib/db.server'

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await prisma.post.findFirst({
    where: { slug: params.slug, status: 'publish' }
  })
  
  if (!post) {
    throw new Response('Not Found', { status: 404 })
  }
  
  return json({ post })
}

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data?.post) return [{ title: 'Post Not Found' }]
  
  return [
    { title: data.post.metaTitle || data.post.title },
    { name: 'description', content: data.post.metaDescription || '' },
  ]
}

export default function BlogPost() {
  const { post } = useLoaderData<typeof loader>()
  
  return (
    <article className="container mx-auto px-4 py-8 max-w-3xl">
      {post.featuredImage && (
        <img
          src={post.featuredImage}
          alt={post.title}
          className="w-full h-64 object-cover rounded-lg mb-8"
        />
      )}
      
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      
      <div className="flex items-center gap-4 text-gray-600 mb-8">
        <time>{new Date(post.publishedAt).toLocaleDateString()}</time>
        <div className="flex gap-2">
          {post.tags?.map((tag: string) => (
            <span key={tag} className="px-2 py-1 bg-gray-100 text-sm rounded">
              {tag}
            </span>
          ))}
        </div>
      </div>
      
      <div
        className="prose prose-lg prose-gray max-w-none"
        dangerouslySetInnerHTML={{ __html: post.html }}
      />
    </article>
  )
}

โš™๏ธ 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_remix_123",
    "title": "Test Remix Post",
    "slug": "test-remix-post",
    "html": "<p className="text-gray-700">This is a test post in Remix.</p>",
    "tags": ["remix", "react"]
  }'

# Fetch posts
curl http://localhost:3000/api/posts

Need Help?

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