Public Boards and Anonymous Feedback in Makerkit Next.js Prisma
Create public-facing pages for collecting customer feedback without authentication. Learn Next.js route groups, anonymous submissions and SEO optimization.
Until now, all of TeamPulse has been behind authentication. But the real power of a feedback tool comes from letting customers submit feedback without creating an account.
In this module, you'll create public-facing board pages where anyone can view feedback and submit their own ideas.
You'll learn how Next.js route groups separate public pages from authenticated ones and how to handle anonymous submissions safely.
Time: ~1-2 hours
What you'll accomplish:
- Create public routes outside the authentication boundary
- Build public board pages with proper SEO metadata
- Implement anonymous feedback submission with a system user
- Give organization admins control over board visibility
Technologies used:
- Next.js Route Groups - Public vs internal routes
- Next.js Metadata - SEO optimization
Prerequisites: Complete Module 3: Feedback Items first.
When an organization marks a board as public, it becomes accessible at a shareable URL.
Customers can view all feedback on the board, submit their own ideas, and optionally vote on existing items.
The URL structure includes both the organization slug and board slug, making each public board uniquely addressable and SEO-friendly.
Public URL: /feedback/[orgSlug]/[boardSlug]Features:├── View feedback list (sorted by votes)├── Submit new feedback (anonymous or with email)├── Vote on feedback (requires email or sign in)└── Organization brandingEach board has visibility controls: isPublic enables public access, allowAnonymous controls whether email is required for submissions.
Step 1: Add Public Fields to Board Schema
Before creating public routes, you need to extend the boards schema with fields that control visibility and submission rules. These boolean fields give organization admins fine-grained control over how their public boards behave.
To modify the schema, we need to create a so-called database migration. This is a file that contains the SQL commands to modify the database. When applied, the database migration will update the database to the new schema.
While it doesn't apply to our case, just for your reference, be aware that there are certain precautions you need to take care of when creating a database migration with already existing data in the database:
- Adding NOT NULL columns — Must include a default value, or add as nullable first, backfill, then alter to
NOT NULL - Renaming columns/tables — Immediately breaks app code; requires multi-step deployment (add new, backfill, update code, drop old)
- Changing column types — Some conversions rewrite the entire table and lock it; problematic for large tables
- Adding indexes — Use
CREATE INDEX CONCURRENTLYto avoid blocking writes (but check for invalid indexes afterward) - Foreign key constraints — Validates all existing rows, which can be slow; consider adding as
NOT VALIDthenVALIDATE CONSTRAINTseparately - Large backfills in one transaction — Holds locks the entire time; batch outside a single transaction for big data migrations
Without further ado, let's add the new fields to the boards schema:
packages/database/src/prisma/schema.prisma
model Board { id String @id organizationId String @map("organization_id") name String description String? slug String // New public access fields isPublic Boolean @default(false) @map("is_public") allowAnonymous Boolean @default(true) @map("allow_anonymous") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) feedbackItems FeedbackItem[] @@index([organizationId], name: "boards_organization_id_idx") @@index([slug], name: "boards_slug_idx") @@index([isPublic], name: "boards_is_public_idx") @@map("boards")}As you can see, we added the new fields to the boards schema.
- The
isPublicfield is a boolean field that indicates if the board is public. - The
allowAnonymousfield is a boolean field that indicates if anonymous submissions are allowed.
We also added new indexes to the boards table to improve the performance of the queries.
Now, let's run the migration to update the database schema:
pnpm --filter @kit/database prisma:generateThis will create a new migration file in the packages/database/migrations directory. You can then open the migration file and review the changes.
pnpm --filter @kit/database prisma:migrateInspect the Database state in Prisma Studio to verify that the new fields are added to the boards table.
Step 2: Create the Public Route Group
Next.js route groups (directories wrapped in parentheses) organize routes without affecting the URL.
By creating a (public) group, you can define a separate layout that doesn't include authentication — visitors won't see the sidebar, user menu, or any authenticated UI.
Let's create the public route structure:
apps/web/app/[locale]/├── (internal)/ # Authenticated routes (existing)│ └── boards/└── (public)/ # New public routes └── feedback/ └── [orgSlug]/ └── [boardSlug]/ └── page.tsxThe public layout is minimal — just a header with the app name and a container for the main content. No sidebar, no user menu, no authentication UI.
apps/web/app/[locale]/(public)/feedback/layout.tsx
import type { ReactNode } from 'react';interface PublicLayoutProps { children: ReactNode;}export default function PublicLayout({ children }: PublicLayoutProps) { return ( <div className="bg-background min-h-screen"> <main className="container mx-auto px-4 py-8">{children}</main> </div> );}Step 3: Create the Public Board Loader
The public board loader differs from the internal loader in two ways: it doesn't require authentication, and it only returns boards marked as public.
The loader joins boards with organizations to get both slugs for the URL, and it filters to ensure only public boards are accessible.
apps/web/lib/feedback/public-board.loader.ts
import 'server-only';import { cache } from 'react';import { db } from '@kit/database';export const loadPublicBoard = cache( async (orgSlug: string, boardSlug: string) => { const board = await db.board.findFirst({ where: { slug: boardSlug, isPublic: true, organization: { slug: orgSlug, }, }, select: { id: true, name: true, description: true, slug: true, isPublic: true, allowAnonymous: true, organization: { select: { id: true, name: true, slug: true, }, }, }, }); if (!board) { return null; } // Return structured object for cleaner component access return { board: { id: board.id, name: board.name, description: board.description, slug: board.slug, isPublic: board.isPublic, allowAnonymous: board.allowAnonymous, }, organization: board.organization, }; },);export const loadPublicFeedbackItems = cache(async (boardId: string) => { const items = await db.feedbackItem.findMany({ where: { boardId }, select: { id: true, title: true, description: true, type: true, status: true, voteCount: true, createdAt: true, author: { select: { name: true, }, }, }, orderBy: { voteCount: 'desc' }, }); return items;});export type PublicBoard = NonNullable< Awaited<ReturnType<typeof loadPublicBoard>>>;export type PublicFeedbackItems = Awaited< ReturnType<typeof loadPublicFeedbackItems>>;Step 4: Create the Public Board Page
The public board page displays the feedback list and submission form.
It generates SEO metadata dynamically based on the board and organization names, and includes Open Graph tags for social sharing. If the board doesn't exist or isn't public, the page returns a 404.
The logic works in the following way:
- Public Boards: if a board is public, users can see the feedback list. For example, you may want to show your users what you're working on, without necessarily allowing them to have a say in it
- Anonymous Submissions: if the board allows anonymous submissions, we also display a form for users to submit feedback - even if they're not part of your organization (so, anybody else)
apps/web/app/[locale]/(public)/feedback/[orgSlug]/[boardSlug]/page.tsx
import type { Metadata } from 'next';import { notFound } from 'next/navigation';import { loadPublicBoard, loadPublicFeedbackItems,} from '@lib/feedback/public-board.loader';import { Badge } from '@kit/ui/badge';import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';import { If } from '@kit/ui/if';import { PublicFeedbackCard } from './_components/public-feedback-card';import { PublicFeedbackForm } from './_components/public-feedback-form';interface PublicBoardPageProps { params: Promise<{ orgSlug: string; boardSlug: string; }>;}export async function generateMetadata({ params,}: PublicBoardPageProps): Promise<Metadata> { const { orgSlug, boardSlug } = await params; const result = await loadPublicBoard(orgSlug, boardSlug); if (!result) { notFound(); } return { title: `${result.board.name} - ${result.organization.name} Feedback`, description: result.board.description ?? `Submit feedback for ${result.board.name}`, openGraph: { title: result.board.name, description: result.board.description ?? `Submit feedback for ${result.board.name}`, type: 'website', }, };}export default async function PublicBoardPage({ params,}: PublicBoardPageProps) { const { orgSlug, boardSlug } = await params; const result = await loadPublicBoard(orgSlug, boardSlug); if (!result) { notFound(); } const { board, organization } = result; return ( <div className="mx-auto max-w-3xl space-y-8"> {/* Header */} <div className="text-center"> <Badge variant="outline" className="mb-2"> {organization.name} </Badge> <h1 className="text-3xl font-bold">{board.name}</h1> {board.description && ( <p className="text-muted-foreground mt-2">{board.description}</p> )} </div> {/* Submit Feedback */} <If condition={board.allowAnonymous}> <Card> <CardHeader> <CardTitle>Submit Feedback</CardTitle> </CardHeader> <CardContent> <PublicFeedbackForm boardId={board.id} /> </CardContent> </Card> </If> {/* Feedback List */} <div className="space-y-4"> <h2 className="text-xl font-semibold">Feedback</h2> <FeedbackList boardId={board.id} /> </div> </div> );}async function FeedbackList({ boardId }: { boardId: string }) { const feedbackItems = await loadPublicFeedbackItems(boardId); if (feedbackItems.length === 0) { return ( <Card className="p-8 text-center"> <p className="text-muted-foreground"> No feedback yet. Be the first to share your thoughts! </p> </Card> ); } return ( <div className="space-y-3"> {feedbackItems.map((item) => ( <PublicFeedbackCard key={item.id} item={item} /> ))} </div> );}We have some missing components in this page, so you will see some errors! Don't worry - we're about to define them right away.
Step 5: Create Public Feedback Card
The public feedback card is a simplified version of the internal card.
It shows vote counts as read-only (full voting requires authentication or email verification, which you could add as an enhancement).
Anonymous authors display as "Anonymous" instead of showing a name.
Let's create the public feedback card component:
apps/web/app/[locale]/(public)/feedback/[orgSlug]/[boardSlug]/_components/public-feedback-card.tsx
'use client';import { Bug, ChevronUp, Lightbulb, Sparkles } from 'lucide-react';import type { PublicFeedbackItems } from '@lib/feedback/public-board.loader';import { Badge } from '@kit/ui/badge';import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';import { cn } from '@kit/ui/utils';type FeedbackItem = PublicFeedbackItems[number];const typeIcons = { bug: Bug, feature: Sparkles, idea: Lightbulb,};const typeColors = { bug: 'text-red-500', feature: 'text-purple-500', idea: 'text-yellow-500',};const statusColors = { new: 'bg-gray-100 text-gray-800', planned: 'bg-blue-100 text-blue-800', in_progress: 'bg-yellow-100 text-yellow-800', done: 'bg-green-100 text-green-800', closed: 'bg-gray-100 text-gray-500',};const statusLabels = { new: 'New', planned: 'Planned', in_progress: 'In Progress', done: 'Done', closed: 'Closed',};interface PublicFeedbackCardProps { item: FeedbackItem;}export function PublicFeedbackCard({ item }: PublicFeedbackCardProps) { const TypeIcon = typeIcons[item.type]; return ( <Card> <div className="flex"> {/* Vote count (view-only) */} <div className="bg-muted/30 flex flex-col items-center justify-start border-r p-4"> <ChevronUp className="text-muted-foreground h-5 w-5" /> <span className="text-sm font-semibold">{item.voteCount}</span> </div> {/* Content */} <div className="flex-1"> <CardHeader className="pb-2"> <div className="flex items-start justify-between gap-2"> <div className="flex items-center gap-2"> <TypeIcon className={cn('h-4 w-4', typeColors[item.type])} /> <CardTitle className="text-base">{item.title}</CardTitle> </div> <Badge className={statusColors[item.status]} variant="secondary"> {statusLabels[item.status]} </Badge> </div> </CardHeader> <CardContent> {item.description && ( <p className="text-muted-foreground mb-2 line-clamp-3 text-sm"> {item.description} </p> )} <div className="text-muted-foreground flex items-center gap-2 text-xs"> <span>{item.author?.name ?? 'Anonymous'}</span> <span>•</span> <span>{item.createdAt.toLocaleDateString()}</span> </div> </CardContent> </div> </div> </Card> );}Step 6: Create Public Feedback Form
The public feedback form includes optional name and email fields. If the board allows anonymous submissions, these fields are optional; otherwise, email is required.
The form calls a separate server action designed for unauthenticated submissions.
apps/web/app/[locale]/(public)/feedback/[orgSlug]/[boardSlug]/_components/public-feedback-form.tsx
'use client';import { useAction } from 'next-safe-action/hooks';import { zodResolver } from '@hookform/resolvers/zod';import { PublicFeedbackInput, publicFeedbackSchema,} from '@lib/feedback/public-board-form.schema';import { submitPublicFeedbackAction } from '@lib/feedback/public-feedback-actions';import { useForm } from 'react-hook-form';import { Button } from '@kit/ui/button';import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from '@kit/ui/form';import { Input } from '@kit/ui/input';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@kit/ui/select';import { toast } from '@kit/ui/sonner';import { Textarea } from '@kit/ui/textarea';interface PublicFeedbackFormProps { boardId: string;}export function PublicFeedbackForm({ boardId }: PublicFeedbackFormProps) { const { executeAsync, status } = useAction(submitPublicFeedbackAction); const form = useForm({ resolver: zodResolver(publicFeedbackSchema), defaultValues: { boardId, title: '', description: '', type: 'idea', email: '', name: '', }, }); const isPending = status === 'executing'; const onSubmit = async (data: PublicFeedbackInput) => { const result = await executeAsync(data); if (result?.serverError) { toast.error(`There was an error submitting your feedback`); return; } if (result?.validationErrors) { toast.error('Please check your input'); return; } toast.success('Thank you for your feedback!'); form.reset(); }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <div className="grid gap-4 sm:grid-cols-2"> <FormField name="name" render={({ field }) => ( <FormItem> <FormLabel>Your Name</FormLabel> <FormControl render={ <Input placeholder="Jane Doe" disabled={isPending} {...field} /> } /> <FormMessage /> </FormItem> )} /> <FormField name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl render={ <Input required type="email" placeholder="jane@example.com" disabled={isPending} {...field} /> } /> <FormMessage /> </FormItem> )} /> </div> <FormField name="type" render={({ field }) => ( <FormItem> <FormLabel>Type</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value} disabled={isPending} > <FormControl render={ <SelectTrigger> <SelectValue> {(value) => value || 'Select type'} </SelectValue> </SelectTrigger> } /> <SelectContent> <SelectItem value="bug">Bug Report</SelectItem> <SelectItem value="feature">Feature Request</SelectItem> <SelectItem value="idea">Idea</SelectItem> </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <FormField name="title" render={({ field }) => ( <FormItem> <FormLabel>Title</FormLabel> <FormControl render={ <Input placeholder="Brief summary of your feedback" disabled={isPending} {...field} /> } /> <FormMessage /> </FormItem> )} /> <FormField name="description" render={({ field }) => ( <FormItem> <FormLabel>Details (optional)</FormLabel> <FormControl render={ <Textarea placeholder="Provide more context..." disabled={isPending} rows={4} {...field} /> } /> <FormMessage /> </FormItem> )} /> <Button type="submit" disabled={isPending} className="w-full"> {isPending ? 'Submitting...' : 'Submit Feedback'} </Button> </form> </Form> );}
Step 7: Create Public Feedback Action
The public feedback action uses a different action client than internal actions.
Instead of authenticatedActionClient (which requires a logged-in user), we create a bare publicActionClient that accepts unauthenticated requests.
The action still validates that the board exists and is public before accepting submissions.
Let's create the public feedback action:
apps/web/lib/feedback/public-feedback-actions.ts
'use server';import { revalidatePath } from 'next/cache';import { createSafeActionClient } from 'next-safe-action';import { db } from '@kit/database';import { getLogger } from '@kit/shared/logger';import { generateId } from '@kit/shared/utils';import { publicFeedbackSchema } from './public-board-form.schema';// Note: We create a separate action client because authenticatedActionClient// from '@kit/action-middleware' requires a logged-in user.// For public submissions, we need an unauthenticated client.const publicActionClient = createSafeActionClient();export const submitPublicFeedbackAction = publicActionClient .inputSchema(publicFeedbackSchema) .action(async ({ parsedInput: data }) => { const logger = await getLogger(); // Verify board exists and is public const board = await db.board.findUnique({ where: { id: data.boardId }, select: { id: true, isPublic: true, allowAnonymous: true, organizationId: true, slug: true, }, }); if (!board) { throw new Error('Board not found'); } if (!board.isPublic) { throw new Error('This board is not public'); } if (!board.allowAnonymous) { throw new Error('Anonymous submissions are not allowed for this board'); } const feedback = await db.feedbackItem.create({ data: { id: generateId(), boardId: data.boardId, title: data.title, description: data.description ?? null, type: data.type, status: 'new', voteCount: 0, }, }); logger.info( { feedbackId: feedback.id }, 'Public feedback submitted successfully', ); revalidatePath(`/feedback/[orgSlug]/[boardSlug]`, 'page'); return { success: true }; });As you can see, we updated the public feedback action to account for the new fields:
- We added a check to verify that the board exists and is public
- We added a check to verify that anonymous submissions (eg. from public users) are allowed for the board
- We insert a new feedback item into the database with the author ID set to the anonymous user ID
Step 8: Add Board Visibility Toggle
Finally, give organization admins the ability to control board visibility from the internal UI.
This requires updating the board form schema to include the new boolean fields, and adding Switch components to the edit form. When a board is public, display the shareable URL so admins can copy and share it.
Let's proceed by updating the board form schema that we created in the previous module to allow for the new fields:
apps/web/lib/boards/boards.schema.ts
export const updateBoardSchema = createBoardSchema.extend({ id: z.string().min(1, 'Board ID is required'), isPublic: z.boolean().optional(), allowAnonymous: z.boolean().optional(),});Let's update the edit board form to include visibility toggles after the description field:
apps/web/app/[locale]/(internal)/boards/_components/edit-board-form.tsx
import { Switch } from '@kit/ui/switch';// In the form, add these fields after the description FormField<FormField name="isPublic" render={({ field }) => ( <FormItem className="flex items-center justify-between rounded-lg border p-4"> <div className="space-y-0.5"> <FormLabel>Public Board</FormLabel> <FormDescription> Allow anyone to view the board </FormDescription> </div> <FormControl render={ <Switch checked={field.value} onCheckedChange={field.onChange} disabled={isPending} /> } /> </FormItem> )}/><FormField name="allowAnonymous" render={({ field }) => ( <FormItem> <div className="space-y-0.5"> <FormLabel>Allow Anonymous submissions</FormLabel> <FormDescription> Allow public board submissions from anonymous users </FormDescription> </div> <FormControl render={ <Switch checked={field.value} onCheckedChange={field.onChange} disabled={isPending} /> } /> </FormItem> )}/>isPublicis a boolean field that indicates if the board is publicallowAnonymousis a boolean field that indicates if anonymous submissions are allowed for the board (eg. from public users)
In addition, we update the Server Action to account for the new fields:
apps/web/lib/boards/boards-server-actions.ts
const board = await db.board.update({ where: { id: data.id, organizationId, }, data: { name: data.name, description: data.description ?? null, slug: generateSlug(data.name), isPublic: data.isPublic, allowAnonymous: data.allowAnonymous, },});Let's show the public URL on the board detail page, so that organizations can share the public URL with their customers.

Next, add this component in the board header section to display the shareable public URL, and import the active organization from the context to retrieve the organization slug.
Here is the updated board detail page, that you can paste in the file:
apps/web/app/[locale]/(internal)/boards/[boardId]/page.tsx
import type { Metadata } from 'next';import { notFound } from 'next/navigation';import { loadFeedbackItems } from '@lib/feedback/feedback-page.loader';import { Plus } from 'lucide-react';import { loadBoard } from '@lib/boards/boards-page.loader';import { getActiveOrganization } from '@kit/better-auth/context';import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { Button } from '@kit/ui/button';import { CopyToClipboard } from '@kit/ui/copy-to-clipboard';import { EmptyState, EmptyStateButton, EmptyStateHeading, EmptyStateText,} from '@kit/ui/empty-state';import { PageBody, PageHeader } from '@kit/ui/page';import { DeleteBoardDialog } from '../_components/delete-board-dialog';import { EditBoardDialog } from '../_components/edit-board-dialog';import { CreateFeedbackDialog } from './_components/create-feedback-dialog';import { FeedbackDataTable } from './_components/feedback-data-table';import { FeedbackFilters } from './_components/feedback-filters';interface FeedbackFilters { page?: number; search?: string; type?: 'bug' | 'feature' | 'idea'; status?: 'new' | 'planned' | 'in_progress' | 'done' | 'closed'; sort?: string;}interface BoardPageProps { params: Promise<{ boardId: string }>; searchParams: Promise<FeedbackFilters>;}export async function generateMetadata({ params,}: BoardPageProps): Promise<Metadata> { const { boardId } = await params; const board = await loadBoard(boardId); return { title: board?.name ?? 'Board Not Found', };}export default async function BoardPage({ params, searchParams,}: BoardPageProps) { const { boardId } = await params; const filters = await searchParams; const [ board, { items: feedbackItems, total, pageIndex, pageSize, pageCount }, organization, ] = await Promise.all([ loadBoard(boardId), loadFeedbackItems(boardId, filters), getActiveOrganization(), ]); if (!board) { notFound(); } const publicUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/feedback/${organization?.slug}/${board.slug}`; return ( <PageBody> <PageHeader> <div className="flex-1"> <AppBreadcrumbs values={{ boards: 'Boards', [board.id]: board.name, }} /> </div> <div className="flex gap-2"> <EditBoardDialog board={board} /> <DeleteBoardDialog boardId={board.id} boardName={board.name} /> </div> </PageHeader> <div className="flex flex-1 flex-col space-y-4 pb-4"> {board.isPublic && ( <div className="bg-muted/50 flex items-center gap-2.5 rounded-lg p-2 text-sm"> <span className="text-muted-foreground">Public URL: </span> <code className="bg-muted rounded px-2 py-1"> <CopyToClipboard value={publicUrl}>{publicUrl}</CopyToClipboard> </code> </div> )} <div className="flex items-center justify-between"> <FeedbackFilters /> <CreateFeedbackDialog boardId={boardId}> <Button size="sm"> <Plus className="mr-2 h-4 w-4" /> Add Feedback </Button> </CreateFeedbackDialog> </div> {feedbackItems.length === 0 ? ( <EmptyState> <EmptyStateHeading>No feedback yet</EmptyStateHeading> <EmptyStateText> Be the first to submit feedback for this board. </EmptyStateText> <CreateFeedbackDialog boardId={boardId}> <EmptyStateButton> <Plus className="mr-2 h-4 w-4" /> Create Feedback </EmptyStateButton> </CreateFeedbackDialog> </EmptyState> ) : ( <FeedbackDataTable data={feedbackItems} total={total} pageIndex={pageIndex} pageSize={pageSize} pageCount={pageCount} /> )} </div> </PageBody> );}
As you can see, we added the public URL to the board detail page - and you can use it to navigate to the public board page we've built earlier in the module.
Checkpoint: Verify It Works
Before moving on, test the full public board flow. Make sure to test both as an authenticated user (to enable public access) and as an unauthenticated visitor (to submit feedback):
- [ ] Enable "Public" on an existing board
- [ ] Visit
/feedback/[orgSlug]/[boardSlug](logged out) - [ ] Verify you can view feedback items
- [ ] Submit new feedback as anonymous user
- [ ] Check that feedback appears in the list
Run the healthcheck command to verify that everything is working correctly:
pnpm run healthcheckArchitecture Notes
These notes explain the design decisions behind public boards. Understanding the "why" helps you adapt these patterns to other public-facing features in your applications.
Why a Separate Route Group?
Using a (public) route group:
- No auth layout - Public pages don't wrap in authenticated layout
- SEO friendly - Proper metadata for search engines
- Performance - No session checks on public pages
- Clear separation - Easy to reason about public vs internal
Security Considerations
- Board-level control - Each board opts into public access
- Anonymous user - Tracks submissions without real accounts
- Input validation - Same validation as internal forms
- CSRF protection - Server actions are CSRF-safe by default
Public endpoints can be tricky, because they can be accessed by anyone, and can be potentially abused.
You have various ways to make sure to sanitize input before you store it in your database:
- Using a Captcha lke Turnstile (Makerkit makes it fairly easy to add)
- Requiring a OTP (Makerkit has the "@kit/otp" package you can use)
- Rate limiting using Redis/Upstash
Homework: you can use the OTP API in Makerkit to send a one-time password to the user's email to verify their identity before submitting feedback. You can finish this in approximately 30-45 minutes.
Module 5 Complete!
You've transformed TeamPulse from an internal tool into a customer-facing feedback platform.
Organizations can now share public URLs, collect feedback from anyone, and control exactly how their public boards behave.
You now have:
- [x] Public route group with a minimal, unauthenticated layout
- [x] Public board pages with dynamic SEO and Open Graph metadata
- [x] Anonymous feedback submission using a system user pattern
- [x] Admin controls for board visibility and submission rules
Next: In Module 6: Authentication, you'll explore Makerkit's authentication system, configure social login providers, and understand how sessions work across your application.
Learn More
- Next.js Route Groups - Organizing routes
- Next.js Metadata - SEO optimization