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/postsNeed Help?
Check our webhook documentation for technical details, or contact support for custom integrations.