Next.js App Router
This recipe shows how to integrate Trokky with a Next.js application using the App Router.
Project Setup
Section titled “Project Setup”Install Dependencies
Section titled “Install Dependencies”npm install @trokky/core @trokky/nextjs @trokky/adapter-filesystem @trokky/clientProject Structure
Section titled “Project Structure”my-nextjs-app/├── app/│ ├── api/│ │ └── [...trokky]/│ │ └── route.ts│ ├── blog/│ │ ├── page.tsx│ │ └── [slug]/│ │ └── page.tsx│ ├── layout.tsx│ └── page.tsx├── lib/│ ├── client.ts│ └── schemas.ts├── content/└── types/ └── content.tsSchemas
Section titled “Schemas”import { Schema } from '@trokky/core';
export const schemas: Schema[] = [ { name: 'post', title: 'Blog Post', fields: [ { name: 'title', type: 'string', required: true }, { name: 'slug', type: 'slug', options: { source: 'title' } }, { name: 'excerpt', type: 'text' }, { name: 'content', type: 'richtext' }, { name: 'featuredImage', type: 'media' }, { name: 'publishedAt', type: 'datetime' }, { name: 'status', type: 'select', default: 'draft', options: { list: [ { value: 'draft', title: 'Draft' }, { value: 'published', title: 'Published' }, ], }, }, ], },];API Route
Section titled “API Route”import { TrokkyNextjs } from '@trokky/nextjs';import { schemas } from '@/lib/schemas';
const trokky = TrokkyNextjs.create({ schemas, storage: { adapter: 'filesystem', contentDir: './content', }, security: { jwtSecret: process.env.TROKKY_JWT_SECRET!, adminUser: { username: process.env.TROKKY_ADMIN_USERNAME!, password: process.env.TROKKY_ADMIN_PASSWORD!, }, },});
export const { GET, POST, PUT, PATCH, DELETE } = trokky.handlers();Client Setup
Section titled “Client Setup”import { createClient } from '@trokky/client';
export const client = createClient({ apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api',});
// Server-side client (with auth if needed)export const serverClient = createClient({ apiUrl: process.env.API_URL || 'http://localhost:3000/api', token: process.env.TROKKY_API_TOKEN,});Blog List Page
Section titled “Blog List Page”import Link from 'next/link';import Image from 'next/image';import { client } from '@/lib/client';
export const revalidate = 60; // Revalidate every minute
async function getPosts() { const posts = await client.documents.list('post', { filter: { status: 'published' }, orderBy: 'publishedAt', order: 'desc', expand: ['featuredImage'], }); return posts.data;}
export default async function BlogPage() { const posts = await getPosts();
return ( <div className="container mx-auto px-4 py-8"> <h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3"> {posts.map((post) => ( <article key={post._id} className="border rounded-lg overflow-hidden"> {post.featuredImage && ( <Image src={post.featuredImage.url} alt={post.featuredImage.alt || post.title} width={400} height={225} className="w-full h-48 object-cover" /> )} <div className="p-4"> <h2 className="text-xl font-semibold mb-2"> <Link href={`/blog/${post.slug.current}`}> {post.title} </Link> </h2> {post.excerpt && ( <p className="text-gray-600">{post.excerpt}</p> )} <time className="text-sm text-gray-500"> {new Date(post.publishedAt).toLocaleDateString()} </time> </div> </article> ))} </div> </div> );}Single Post Page
Section titled “Single Post Page”import { notFound } from 'next/navigation';import Image from 'next/image';import { client } from '@/lib/client';import type { Metadata } from 'next';
interface Props { params: { slug: string };}
async function getPost(slug: string) { const posts = await client.documents.list('post', { filter: { 'slug.current': slug, status: 'published' }, limit: 1, }); return posts.data[0] || null;}
export async function generateStaticParams() { const posts = await client.documents.list('post', { filter: { status: 'published' }, });
return posts.data.map((post) => ({ slug: post.slug.current, }));}
export async function generateMetadata({ params }: Props): Promise<Metadata> { const post = await getPost(params.slug);
if (!post) { return { title: 'Not Found' }; }
return { title: post.metaTitle || post.title, description: post.metaDescription || post.excerpt, openGraph: { title: post.title, description: post.excerpt, images: post.ogImage?.url || post.featuredImage?.url, }, };}
export default async function PostPage({ params }: Props) { const post = await getPost(params.slug);
if (!post) { notFound(); }
return ( <article className="container mx-auto px-4 py-8 max-w-3xl"> {post.featuredImage && ( <Image src={post.featuredImage.url} alt={post.featuredImage.alt || post.title} width={800} height={450} className="w-full rounded-lg mb-8" priority /> )}
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<time className="text-gray-500 block mb-8"> {new Date(post.publishedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', })} </time>
<div className="prose lg:prose-lg" dangerouslySetInnerHTML={{ __html: post.content }} /> </article> );}On-Demand Revalidation
Section titled “On-Demand Revalidation”Revalidation API Route
Section titled “Revalidation API Route”import { revalidatePath, revalidateTag } from 'next/cache';import { NextResponse } from 'next/server';
export async function POST(request: Request) { const body = await request.json(); const { secret, type, slug } = body;
// Verify webhook secret if (secret !== process.env.REVALIDATION_SECRET) { return NextResponse.json({ error: 'Invalid secret' }, { status: 401 }); }
// Revalidate based on content type if (type === 'post') { revalidatePath('/blog'); if (slug) { revalidatePath(`/blog/${slug}`); } }
return NextResponse.json({ revalidated: true });}Configure Webhook in Trokky
Section titled “Configure Webhook in Trokky”webhooks: { endpoints: [ { url: `${process.env.SITE_URL}/api/revalidate`, events: ['document:created', 'document:updated', 'document:deleted'], secret: process.env.REVALIDATION_SECRET, transform: (event) => ({ secret: process.env.REVALIDATION_SECRET, type: event.document._type, slug: event.document.slug?.current, }), }, ],}Environment Variables
Section titled “Environment Variables”TROKKY_JWT_SECRET=your-secret-keyTROKKY_ADMIN_USERNAME=adminTROKKY_ADMIN_PASSWORD=your-passwordNEXT_PUBLIC_API_URL=http://localhost:3000/apiAPI_URL=http://localhost:3000/apiREVALIDATION_SECRET=your-revalidation-secretSITE_URL=http://localhost:3000Type Generation
Section titled “Type Generation”Generate TypeScript types from your schemas:
npx trokky generate-types --api http://localhost:3000/api --output ./types/content.tsUse in components:
import type { Post } from '@/types/content';
const posts = await client.documents.list<Post>('post');