Community
Complete a safe public feed and hosted-customer comment with independent readback.
When a customer withdraws, their posts and comments remain. Account erasure replaces the copied author name with [삭제된 사용자] and removes the handle and avatar. We recommend explaining this content-retention and author-erasure behavior in your app's privacy policy.
Prepare
Enable community and its customers dependency. Prepare a post that is published and visible, not hidden or rejected by moderation, unlocked, and with allowsComments on. Decide who handles moderation and support.
Public reads use a Publishable Key. Participation, such as comments, reactions, and reports, uses a customer JWT from the same workspace; a Secret Key cannot act as a customer. Copy all the Customers files for hosted sign-in: client, origin, and redirect → state/PKCE callback → Secure HttpOnly session → me() → hosted logout. Use that guide's legacy compatibility section only for existing direct-sign-in apps.
The examples run on each request in a Next.js App Router app. Build Options lists the tested versions and setup.
Implement public read and customer participation
Copy all three files. The feed checks for an empty list before choosing a post, handles a null detail separately, and renders only public fields. The Server Action reads the signed-in identity before creating a comment, then looks the comment up separately. New comments default to published and approved; there is no pre-moderation queue. A later moderation change, or the size of the first page, can keep a new comment out of that lookup, so report what the lookup actually returned.
// app/community/page.tsx
import { createClient } from '@01.software/sdk'
export const dynamic = 'force-dynamic'
export default async function CommunityPage() {
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
apiUrl: process.env.SOFTWARE_API_URL,
})
const feed = await client.community.listPosts({ limit: 10 })
if (feed.docs.length === 0) return <p>No public posts yet.</p>
const post = await client.community.getPost({
postId: String(feed.docs[0].id),
})
if (!post) return <p>This post is no longer available.</p>
const comments = await client.community.listComments({
postId: String(post.id),
limit: 10,
})
return (
<main>
<h1>{post.title}</h1>
{comments.docs.length === 0 ? (
<p>No public comments yet.</p>
) : (
<ul>
{comments.docs.map((comment) => (
<li key={comment.id}>{comment.body}</li>
))}
</ul>
)}
<a href={`/community/${encodeURIComponent(String(post.id))}`}>
Join the conversation
</a>
</main>
)
}// app/community/[postId]/actions.ts
'use server'
import { currentCustomer } from '@/lib/customer'
export async function addComment(postId: string, form: FormData) {
const { client, customer } = await currentCustomer()
if (!customer) return { status: 'sign_in_required' }
const body = String(form.get('body') ?? '').trim()
if (!body || body.length > 1000) return { status: 'invalid_comment' }
// Author/tenant/moderation fields are never supplied by the app.
const created = await client.community.createComment({ postId, body })
const comments = await client.community.listComments({
postId,
limit: 10,
sort: '-createdAt',
})
return {
status: comments.docs.some((comment) => comment.id === created.id)
? 'visible'
: 'submitted',
}
}// app/community/[postId]/page.tsx
import { createClient } from '@01.software/sdk'
import { notFound } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { addComment } from './actions'
export const dynamic = 'force-dynamic'
export default async function Conversation({
params,
}: {
params: Promise<{ postId: string }>
}) {
const { postId } = await params
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
apiUrl: process.env.SOFTWARE_API_URL,
})
const post = await client.community.getPost({ postId })
if (!post) return notFound()
const comments = await client.community.listComments({
postId,
limit: 10,
sort: '-createdAt',
})
async function submit(form: FormData) {
'use server'
const result = await addComment(postId, form)
if (result.status === 'sign_in_required') throw new Error('Sign in first')
if (result.status === 'invalid_comment')
throw new Error('Write a comment of 1–1000 characters')
revalidatePath(`/community/${encodeURIComponent(postId)}`)
}
return (
<main>
<h1>{post.title}</h1>
{comments.docs.length === 0 ? (
<p>No public comments yet.</p>
) : (
<ul>
{comments.docs.map((comment) => (
<li key={comment.id}>{comment.body}</li>
))}
</ul>
)}
<a href="/account">Account</a>
<form action={submit}>
<textarea name="body" required maxLength={1000} aria-label="Comment" />
<button>Submit comment</button>
</form>
</main>
)
}Normal, empty, and failure results
Open /community with zero posts, then with one visible post. A post removed between the feed and its detail shows the unavailable state. Open the conversation, sign in through /account, submit one comment, and find it with listComments() and a fresh page load. An empty comment list is different from a failed request. The first page may not include the new comment, and submitted is not proof of rejection or publication; read the right page or ask moderation support before writing again.
| Result | Handling |
|---|---|
| Missing post, post hidden or rejected by moderation, or another workspace's post | Detail returns null, only for the not_found response. |
| Any other 404 (for example, a wrong deployment URL), or an auth, workspace, feature, or network error | Let it propagate. Never turn it into an empty feed. |
Add read and action error UI. Test signed-out and other-workspace writes, a hidden or rejected post, a banned customer, a locked post, and disabled comments: a denial must leave no new comment. After a successful write, look the comment up instead of trusting the HTTP status alone.
The server sets the author, profile, and workspace from the verified customer; the app never sends moderation, author, counter, or provenance fields. A reply must belong to the same post and stay within the nesting limit. Raw posts, comments, reactions, reports, and bans are server-only collections, not browser alternatives. Using a different client cannot give a customer moderation rights.
Next Actions
See Content Operations for moderation and support, SDK for imports and peer dependencies, and Deploy for browser origins and hosted session checks. On logout, deleting the app cookie is different from revoking the hosted session on the server; see Customers.