With the release of the new optimized rules for Makerkit in version 2.10.0, building a SaaS with Makerkit is easier than ever. Claude Code is a new AI Coding Agent that we will be using to build a SaaS with Makerkit.
What is Claude Code?
Claude Code is a new AI Coding Agent that can build a SaaS with AI. It is a new product from Anthropic, the company behind Claude.
What are we building?
We will be building an AI Content Repurposer SaaS with the following features:
- Upload blog posts → Get AI-generated social media variations
- Usage-based billing with credits
This in less about 10 prompts, and a couple of hours of work!
How to use Claude Code to build a SaaS with Makerkit?
You will need:
- Have a Claude subscription and Claude Code installed on your machine
- Have a valid Makerkit license
- Bootstrap a new Makerkit project on your local machine
Step 1: Create a new Makerkit project
git clone https://github.com/makerkit/next-supabase-turbo-saas-kit.git content-repurposercd content-repurposer
Step 2: Install dependencies
pnpm ipnpm gen setup
Please go through the setup process and follow the instructions.
After that, run the following commands to start the Next.js server and Supabase containers:
pnpm run devpnpm run supabase:web:start # requires Docker to be installed and running
Step 3: Getting Started with Claude Code
Please run the command claude
to start the Claude Code agent inside the project directory (very important!).
claude
Claude should be running now. You can see the agent's output in the terminal.
Step 4: Updating the CLAUDE.md with the project requirements
Next, we need to update the CLAUDE.md
file with the project requirements.
At the beginning, I replaced the default Makerkit project description with the following:
AI Content RepurposerFeatures:- Upload blog posts → Get AI-generated social media variations- Usage-based billing with credits- Team collaboration for agencies- Real-time processing with status updates
This helps the AI Agent to understand the project requirements and build the SaaS accordingly.
Premise: Manual changes will be minimal (vibe-coding!)
In building this SaaS, we will be using Claude Code to generate the code, and we will be making minimal manual changes to the code. Like kids call it these days, we will be "vibe-coding" this application in almost its entirety, with manual changes only when necessary or when it would be a waste of tokens to ask Claude Code to do it.
Planning & Database Design
In this step, we will be planning the database schema and the core features of the SaaS. This is one of the most important steps in the development process - and it's where the AI Agent shines.
We start with asking Claude Code to plan the database schema for the SaaS. We want to make sure that the database schema is optimized for the features we want to build, it is safe from vulnerabilities, its schema is reliable and consistent, and has no logical errors.
Design the complete database schema for an AI Content Repurposer SaaS using Makerkit's multi-tenant architecture. Write concise, accurate, reliable, consistent, and secure schema with RLS policies. Do not over-engineer - stick to core repurposing features only. Include necessary constraints for reliability. Follow Makerkit's account-based access control patterns and use public schema with explicit references.
💡 When working with the DB schema, make sure the Agent focuses on both security and reliability of the schema. The database is not the thing we want to mess up.
Claude's first shot is good, but we need to refine it a bit. There are a number of issues with Claude Code's implementation:
- It's a bit more complex than it needs to be, but that's generally okay. You can ask to simplify the schema if you want.
- The function
public.consume_credits
allowsauthenticated
users to consume credits, which is a security vulnerability - since it doesn't restrict the usage of the function to theservice_role
. This allows any authenticated user to consume credits, which is not what we want. Please watch out for this sort of issues. - The
public.set_updated_at
function already exists in the project and is namedtrigger_set_timestamps
.
Perhaps we could have been more careful in the prompt? We should take these into consideration next time. Always consider asking Claude to review its own work, and conduct a security audit of the changes it makes. This is a very important step in the development process.
Let's fix these issues in the next prompt:
Fix these database security and naming issues in the AI Content Repurposer schema: 1) The public.consume_credits
function has a security vulnerability - it allows any authenticated
user to consume credits instead of restricting to service_role
only. Update the function to use SECURITY DEFINER
with proper role restrictions. 2) Replace public.set_updated_at
function with the existing Makerkit function trigger_set_timestamps
in all trigger definitions. Ensure credit consumption can only be called by application code, not end users.
Here's the updated schema that Claude Code generated at apps/web/supabase/schema/18-content-repurposer.sql
:
/* * ------------------------------------------------------- * Section: AI Content Repurposer * Core schema for AI-powered content repurposing SaaS * ------------------------------------------------------- */-- Content types enumerationCREATE TYPE public.content_type AS ENUM ( 'blog_post', 'article', 'social_media_post', 'newsletter', 'video_script', 'podcast_script');-- Content status enumerationCREATE TYPE public.content_status AS ENUM ( 'uploaded', 'processing', 'completed', 'failed');-- Repurposing target platformsCREATE TYPE public.target_platform AS ENUM ( 'twitter', 'linkedin', 'instagram', 'facebook', 'tiktok', 'youtube', 'blog', 'newsletter');-- Content processing priorityCREATE TYPE public.processing_priority AS ENUM ( 'low', 'normal', 'high', 'urgent');-- Original content tableCREATE TABLE IF NOT EXISTS public.original_content ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, -- Content metadata title TEXT NOT NULL, content_type public.content_type NOT NULL, original_text TEXT NOT NULL, word_count INTEGER GENERATED ALWAYS AS (array_length(string_to_array(trim(original_text), ' '), 1)) STORED, -- File storage (if uploaded) file_url TEXT, file_name TEXT, file_size INTEGER, file_type TEXT, -- Processing info status public.content_status NOT NULL DEFAULT 'uploaded', processing_started_at TIMESTAMPTZ, processing_completed_at TIMESTAMPTZ, error_message TEXT, -- Metadata metadata JSONB DEFAULT '{}'::JSONB, tags TEXT[] DEFAULT '{}', -- Audit fields created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, -- Constraints CONSTRAINT valid_word_count CHECK (word_count > 0), CONSTRAINT valid_file_size CHECK (file_size IS NULL OR file_size > 0), CONSTRAINT valid_processing_times CHECK ( processing_started_at IS NULL OR processing_completed_at IS NULL OR processing_completed_at >= processing_started_at ));-- Repurposing jobs tableCREATE TABLE IF NOT EXISTS public.repurposing_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, original_content_id UUID NOT NULL REFERENCES public.original_content(id) ON DELETE CASCADE, -- Job configuration target_platforms public.target_platform[] NOT NULL DEFAULT '{}', custom_instructions TEXT, tone TEXT, -- casual, professional, friendly, etc. priority public.processing_priority NOT NULL DEFAULT 'normal', -- Processing info status public.content_status NOT NULL DEFAULT 'uploaded', processing_started_at TIMESTAMPTZ, processing_completed_at TIMESTAMPTZ, error_message TEXT, -- AI processing metadata ai_model_used TEXT, processing_duration_ms INTEGER, tokens_consumed INTEGER, -- Credits and billing credits_cost INTEGER NOT NULL DEFAULT 1, -- Audit fields created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, -- Constraints CONSTRAINT valid_platforms CHECK (array_length(target_platforms, 1) > 0), CONSTRAINT valid_credits_cost CHECK (credits_cost > 0), CONSTRAINT valid_processing_duration CHECK (processing_duration_ms IS NULL OR processing_duration_ms >= 0), CONSTRAINT valid_tokens_consumed CHECK (tokens_consumed IS NULL OR tokens_consumed >= 0));-- Generated content variations tableCREATE TABLE IF NOT EXISTS public.content_variations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, job_id UUID NOT NULL REFERENCES public.repurposing_jobs(id) ON DELETE CASCADE, original_content_id UUID NOT NULL REFERENCES public.original_content(id) ON DELETE CASCADE, -- Variation details platform public.target_platform NOT NULL, variation_title TEXT, content_text TEXT NOT NULL, character_count INTEGER GENERATED ALWAYS AS (length(content_text)) STORED, -- Platform-specific metadata hashtags TEXT[] DEFAULT '{}', mentions TEXT[] DEFAULT '{}', platform_metadata JSONB DEFAULT '{}'::JSONB, -- platform-specific fields -- Quality and approval ai_confidence_score DECIMAL(3,2), -- 0.00 to 1.00 is_approved BOOLEAN DEFAULT FALSE, approved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, approved_at TIMESTAMPTZ, -- Publishing info is_published BOOLEAN DEFAULT FALSE, published_at TIMESTAMPTZ, external_post_id TEXT, -- ID from social platform external_url TEXT, -- URL to published content -- Audit fields created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT valid_confidence_score CHECK (ai_confidence_score IS NULL OR (ai_confidence_score >= 0 AND ai_confidence_score <= 1)), CONSTRAINT valid_character_count CHECK (character_count > 0), CONSTRAINT approval_consistency CHECK ( (is_approved = FALSE) OR (is_approved = TRUE AND approved_by IS NOT NULL AND approved_at IS NOT NULL) ), CONSTRAINT publishing_consistency CHECK ( (is_published = FALSE) OR (is_published = TRUE AND published_at IS NOT NULL) ));-- Account credits table for usage-based billingCREATE TABLE IF NOT EXISTS public.account_credits ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, -- Credit balance total_credits INTEGER NOT NULL DEFAULT 0, used_credits INTEGER NOT NULL DEFAULT 0, remaining_credits INTEGER GENERATED ALWAYS AS (total_credits - used_credits) STORED, -- Billing period billing_period_start TIMESTAMPTZ NOT NULL, billing_period_end TIMESTAMPTZ NOT NULL, -- Metadata plan_name TEXT, auto_refill BOOLEAN DEFAULT TRUE, -- Audit fields created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT valid_credits CHECK (total_credits >= 0 AND used_credits >= 0 AND used_credits <= total_credits), CONSTRAINT valid_billing_period CHECK (billing_period_end > billing_period_start), -- Ensure one active period per account UNIQUE(account_id, billing_period_start));-- Credit transactions table for audit trailCREATE TABLE IF NOT EXISTS public.credit_transactions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, account_credits_id UUID NOT NULL REFERENCES public.account_credits(id) ON DELETE CASCADE, -- Transaction details transaction_type TEXT NOT NULL, -- 'purchase', 'usage', 'refund', 'bonus' amount INTEGER NOT NULL, -- positive for additions, negative for usage description TEXT NOT NULL, -- Related entities job_id UUID REFERENCES public.repurposing_jobs(id) ON DELETE SET NULL, order_id TEXT, -- external billing system order ID -- Balance snapshot balance_before INTEGER NOT NULL, balance_after INTEGER NOT NULL, -- Audit fields created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, -- Constraints CONSTRAINT valid_amount CHECK (amount != 0), CONSTRAINT valid_balance_calculation CHECK (balance_after = balance_before + amount), CONSTRAINT valid_transaction_type CHECK (transaction_type IN ('purchase', 'usage', 'refund', 'bonus')));-- Content templates table for reusable configurationsCREATE TABLE IF NOT EXISTS public.content_templates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, -- Template details name TEXT NOT NULL, description TEXT, content_type public.content_type NOT NULL, target_platforms public.target_platform[] NOT NULL DEFAULT '{}', -- Template configuration custom_instructions TEXT, tone TEXT, default_tags TEXT[] DEFAULT '{}', -- Usage tracking usage_count INTEGER NOT NULL DEFAULT 0, last_used_at TIMESTAMPTZ, -- Sharing is_public BOOLEAN DEFAULT FALSE, is_archived BOOLEAN DEFAULT FALSE, -- Audit fields created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, -- Constraints CONSTRAINT valid_platforms_template CHECK (array_length(target_platforms, 1) > 0), CONSTRAINT valid_usage_count CHECK (usage_count >= 0));-- Enable Row Level Security on all tablesALTER TABLE public.original_content ENABLE ROW LEVEL SECURITY;ALTER TABLE public.repurposing_jobs ENABLE ROW LEVEL SECURITY;ALTER TABLE public.content_variations ENABLE ROW LEVEL SECURITY;ALTER TABLE public.account_credits ENABLE ROW LEVEL SECURITY;ALTER TABLE public.credit_transactions ENABLE ROW LEVEL SECURITY;ALTER TABLE public.content_templates ENABLE ROW LEVEL SECURITY;-- RLS Policies for original_contentCREATE POLICY "Users can read their account's original content" ON public.original_content FOR SELECT USING (public.has_role_on_account(account_id));CREATE POLICY "Users can insert original content for their accounts" ON public.original_content FOR INSERT WITH CHECK (public.has_role_on_account(account_id));CREATE POLICY "Users can update their account's original content" ON public.original_content FOR UPDATE USING (public.has_role_on_account(account_id)) WITH CHECK (public.has_role_on_account(account_id));CREATE POLICY "Users can delete their account's original content" ON public.original_content FOR DELETE USING (public.has_role_on_account(account_id));-- RLS Policies for repurposing_jobsCREATE POLICY "Users can read their account's repurposing jobs" ON public.repurposing_jobs FOR SELECT USING (public.has_role_on_account(account_id));CREATE POLICY "Users can insert repurposing jobs for their accounts" ON public.repurposing_jobs FOR INSERT WITH CHECK (public.has_role_on_account(account_id));CREATE POLICY "Users can update their account's repurposing jobs" ON public.repurposing_jobs FOR UPDATE USING (public.has_role_on_account(account_id)) WITH CHECK (public.has_role_on_account(account_id));CREATE POLICY "Users can delete their account's repurposing jobs" ON public.repurposing_jobs FOR DELETE USING (public.has_role_on_account(account_id));-- RLS Policies for content_variationsCREATE POLICY "Users can read their account's content variations" ON public.content_variations FOR SELECT USING (public.has_role_on_account(account_id));CREATE POLICY "Users can insert content variations for their accounts" ON public.content_variations FOR INSERT WITH CHECK (public.has_role_on_account(account_id));CREATE POLICY "Users can update their account's content variations" ON public.content_variations FOR UPDATE USING (public.has_role_on_account(account_id)) WITH CHECK (public.has_role_on_account(account_id));CREATE POLICY "Users can delete their account's content variations" ON public.content_variations FOR DELETE USING (public.has_role_on_account(account_id));-- RLS Policies for account_creditsCREATE POLICY "Users can read their account's credits" ON public.account_credits FOR SELECT USING (public.has_role_on_account(account_id));CREATE POLICY "Service role can manage account credits" ON public.account_credits FOR ALL USING (auth.role() = 'service_role');-- RLS Policies for credit_transactionsCREATE POLICY "Users can read their account's credit transactions" ON public.credit_transactions FOR SELECT USING (public.has_role_on_account(account_id));CREATE POLICY "Service role can manage credit transactions" ON public.credit_transactions FOR ALL USING (auth.role() = 'service_role');-- RLS Policies for content_templatesCREATE POLICY "Users can read their account's templates and public templates" ON public.content_templates FOR SELECT USING ( public.has_role_on_account(account_id) OR (is_public = TRUE AND is_archived = FALSE) );CREATE POLICY "Users can insert templates for their accounts" ON public.content_templates FOR INSERT WITH CHECK (public.has_role_on_account(account_id));CREATE POLICY "Users can update their account's templates" ON public.content_templates FOR UPDATE USING (public.has_role_on_account(account_id)) WITH CHECK (public.has_role_on_account(account_id));CREATE POLICY "Users can delete their account's templates" ON public.content_templates FOR DELETE USING (public.has_role_on_account(account_id));-- Indexes for performanceCREATE INDEX IF NOT EXISTS idx_original_content_account_id ON public.original_content(account_id);CREATE INDEX IF NOT EXISTS idx_original_content_status ON public.original_content(status);CREATE INDEX IF NOT EXISTS idx_original_content_created_at ON public.original_content(created_at DESC);CREATE INDEX IF NOT EXISTS idx_original_content_type ON public.original_content(content_type);CREATE INDEX IF NOT EXISTS idx_repurposing_jobs_account_id ON public.repurposing_jobs(account_id);CREATE INDEX IF NOT EXISTS idx_repurposing_jobs_original_content_id ON public.repurposing_jobs(original_content_id);CREATE INDEX IF NOT EXISTS idx_repurposing_jobs_status ON public.repurposing_jobs(status);CREATE INDEX IF NOT EXISTS idx_repurposing_jobs_priority ON public.repurposing_jobs(priority);CREATE INDEX IF NOT EXISTS idx_repurposing_jobs_created_at ON public.repurposing_jobs(created_at DESC);CREATE INDEX IF NOT EXISTS idx_content_variations_account_id ON public.content_variations(account_id);CREATE INDEX IF NOT EXISTS idx_content_variations_job_id ON public.content_variations(job_id);CREATE INDEX IF NOT EXISTS idx_content_variations_platform ON public.content_variations(platform);CREATE INDEX IF NOT EXISTS idx_content_variations_approved ON public.content_variations(is_approved);CREATE INDEX IF NOT EXISTS idx_content_variations_published ON public.content_variations(is_published);CREATE INDEX IF NOT EXISTS idx_account_credits_account_id ON public.account_credits(account_id);CREATE INDEX IF NOT EXISTS idx_account_credits_billing_period ON public.account_credits(billing_period_start, billing_period_end);CREATE INDEX IF NOT EXISTS idx_credit_transactions_account_id ON public.credit_transactions(account_id);CREATE INDEX IF NOT EXISTS idx_credit_transactions_created_at ON public.credit_transactions(created_at DESC);CREATE INDEX IF NOT EXISTS idx_credit_transactions_type ON public.credit_transactions(transaction_type);CREATE INDEX IF NOT EXISTS idx_content_templates_account_id ON public.content_templates(account_id);CREATE INDEX IF NOT EXISTS idx_content_templates_public ON public.content_templates(is_public) WHERE is_public = TRUE;CREATE INDEX IF NOT EXISTS idx_content_templates_content_type ON public.content_templates(content_type);-- Triggers for updated_at timestamps using Makerkit's standard functionCREATE TRIGGER set_original_content_updated_at BEFORE UPDATE ON public.original_content FOR EACH ROW EXECUTE FUNCTION public.trigger_set_timestamps();CREATE TRIGGER set_repurposing_jobs_updated_at BEFORE UPDATE ON public.repurposing_jobs FOR EACH ROW EXECUTE FUNCTION public.trigger_set_timestamps();CREATE TRIGGER set_content_variations_updated_at BEFORE UPDATE ON public.content_variations FOR EACH ROW EXECUTE FUNCTION public.trigger_set_timestamps();CREATE TRIGGER set_account_credits_updated_at BEFORE UPDATE ON public.account_credits FOR EACH ROW EXECUTE FUNCTION public.trigger_set_timestamps();CREATE TRIGGER set_content_templates_updated_at BEFORE UPDATE ON public.content_templates FOR EACH ROW EXECUTE FUNCTION public.trigger_set_timestamps();-- Helper functions for credit managementCREATE OR REPLACE FUNCTION public.consume_credits( p_account_id UUID, p_amount INTEGER, p_job_id UUID, p_description TEXT DEFAULT 'Content repurposing')RETURNS BOOLEANLANGUAGE plpgsqlSECURITY DEFINERSET search_path TO ''AS $$DECLARE v_credits_id UUID; v_current_balance INTEGER;BEGIN -- Security check: only allow service_role to execute this function IF auth.role() != 'service_role' THEN RAISE EXCEPTION 'Only service role can consume credits'; END IF; -- Get current active credits for account SELECT id, remaining_credits INTO v_credits_id, v_current_balance FROM public.account_credits WHERE account_id = p_account_id AND billing_period_start <= NOW() AND billing_period_end > NOW() ORDER BY billing_period_start DESC LIMIT 1; -- Check if account has enough credits IF v_credits_id IS NULL OR v_current_balance < p_amount THEN RETURN FALSE; END IF; -- Update used credits UPDATE public.account_credits SET used_credits = used_credits + p_amount WHERE id = v_credits_id; -- Record transaction INSERT INTO public.credit_transactions ( account_id, account_credits_id, transaction_type, amount, description, job_id, balance_before, balance_after ) VALUES ( p_account_id, v_credits_id, 'usage', -p_amount, p_description, p_job_id, v_current_balance, v_current_balance - p_amount ); RETURN TRUE;END;$$;-- Grant necessary permissions (service_role only)GRANT EXECUTE ON FUNCTION public.consume_credits TO service_role;-- Comments for documentationCOMMENT ON TABLE public.original_content IS 'Stores original content uploaded by users for repurposing';COMMENT ON TABLE public.repurposing_jobs IS 'Tracks AI repurposing jobs with configuration and status';COMMENT ON TABLE public.content_variations IS 'Stores AI-generated content variations for different platforms';COMMENT ON TABLE public.account_credits IS 'Manages credit balances for usage-based billing';COMMENT ON TABLE public.credit_transactions IS 'Audit trail for all credit-related transactions';COMMENT ON TABLE public.content_templates IS 'Reusable templates for content repurposing configurations';COMMENT ON FUNCTION public.consume_credits IS 'Safely consume credits for a job and record transaction';
Applying the schema
Let's apply the schema to the Supabase database. Since it's a brand new schema, we just create the migration as is using the schema file
pnpm --filter web supabase migration new content-repurposer
The output should look like this:
Created new migration at supabase/migrations/<timestamp>_content-repurposer.sql
Now, copy the schema content into the migration file:
cat apps/web/supabase/schemas/18-content-repurposer.sql > apps/web/supabase/migrations/<timestamp>_content-repurposer.sql
Please replace <timestamp>
with the actual timestamp of the migration.
Now, let's apply the migration to the Supabase database. This will reset the Supabase database and apply the migration:
pnpm run supabase:web:reset
Core Upload Feature
Now that the database is ready, we can start building the core features of the SaaS. This will involve a number of steps:
- Create the content upload page where users can paste URLs or upload markdown files, with form validation and file storage in the team account context (eg.
/home/<account>/upload
) - Create the server action to handle the upload
- Displaying the uploaded content in the team account context (eg.
/home/<account>
) home page (we can replace the existing dashboard)
💡 When creating new tasks for the AI Agent, it's very important to be specific and to the point - and not overwhelm it with too many details. Do one task at a time, validate it, and then move on to the next task - but never ask the agent to do all at once ("Hey make me a SaaS with X, Y, Z features").
Step 1: Create the content upload page
Let's start with the first step.
Create a content upload page at
/home/[account]/upload
in Makerkit. Build a form with URL input field and markdown file upload, include Zod validation schema, handle file storage in Supabase for the team account context. Use Makerkit's form patterns and account-based routing structure. Create a server action to handle the upload but do not implement the actual AI processing yet. We will handle it in the next step.
Let's go through the changes that Claude Code made to the codebase:
The Zod schema
Claude Code correctly created a Zod schema for the content upload form as an external file so that it can be reused in the form component and in the server action:
import { z } from 'zod';export const ContentUploadSchema = z.object({ title: z.string().min(1, 'Title is required').max(200, 'Title is too long'), contentType: z.enum(['blog_post', 'article', 'social_media_post', 'newsletter', 'video_script', 'podcast_script'], { required_error: 'Content type is required', }), // Either URL or file upload is required contentUrl: z.string().url('Please enter a valid URL').optional().or(z.literal('')), // File validation file: z .custom<File>((val) => val instanceof File, 'Please upload a file') .refine( (file) => file.size <= 5 * 1024 * 1024, // 5MB limit 'File size must be less than 5MB' ) .refine( (file) => ['text/markdown', 'text/plain', 'application/octet-stream'].includes(file.type) || file.name.endsWith('.md') || file.name.endsWith('.txt'), 'Only markdown (.md) and text (.txt) files are supported' ) .optional(), tags: z.array(z.string()).optional().default([]), customInstructions: z.string().max(1000, 'Instructions are too long').optional(),}).refine( (data) => data.contentUrl || data.file, { message: 'Either provide a URL or upload a file', path: ['contentUrl'], });export type ContentUploadFormData = z.infer<typeof ContentUploadSchema>;
The page to upload content
import { use } from 'react';import { PageBody } from '@kit/ui/page';import { Trans } from '@kit/ui/trans';import { createI18nServerInstance } from '~/lib/i18n/i18n.server';import { withI18n } from '~/lib/i18n/with-i18n';import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';import { ContentUploadForm } from './_components/content-upload-form';interface UploadPageProps { params: Promise<{ account: string }>;}export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); const title = i18n.t('upload:pageTitle', { defaultValue: 'Upload Content' }); return { title, };};function UploadPage({ params }: UploadPageProps) { const account = use(params).account; return ( <> <TeamAccountLayoutPageHeader account={account} title={<Trans i18nKey="upload:pageTitle" defaults="Upload Content" />} description={ <Trans i18nKey="upload:pageDescription" defaults="Upload content from a URL or file to start repurposing it for different platforms" /> } /> <PageBody> <div className="space-y-6"> <div className="text-center space-y-2"> <h2 className="text-2xl font-semibold"> <Trans i18nKey="upload:formTitle" defaults="Add New Content" /> </h2> <p className="text-muted-foreground"> <Trans i18nKey="upload:formSubtitle" defaults="Share a URL or upload a file to get AI-generated variations for social media and other platforms" /> </p> </div> <ContentUploadForm /> </div> </PageBody> </> );}export default withI18n(UploadPage);
And the form component:
'use client';import { useState, useTransition } from 'react';import { useRouter } from 'next/navigation';import { zodResolver } from '@hookform/resolvers/zod';import { useForm } from 'react-hook-form';import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';import { Button } from '@kit/ui/button';import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@kit/ui/form';import { If } from '@kit/ui/if';import { Input } from '@kit/ui/input';import { Label } from '@kit/ui/label';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@kit/ui/select';import { Separator } from '@kit/ui/separator';import { toast } from '@kit/ui/sonner';import { Spinner } from '@kit/ui/spinner';import { Textarea } from '@kit/ui/textarea';import { Trans } from '@kit/ui/trans';import { type ContentUploadFormData, ContentUploadSchema,} from '../_lib/schema/content-upload.schema';import { uploadContentAction } from '../_lib/server/server-actions';const contentTypeOptions = [ { value: 'blog_post', label: 'Blog Post' }, { value: 'article', label: 'Article' }, { value: 'social_media_post', label: 'Social Media Post' }, { value: 'newsletter', label: 'Newsletter' }, { value: 'video_script', label: 'Video Script' }, { value: 'podcast_script', label: 'Podcast Script' },] as const;export function ContentUploadForm() { const [isPending, startTransition] = useTransition(); const [uploadMode, setUploadMode] = useState<'url' | 'file'>('url'); const router = useRouter(); const { account } = useTeamAccountWorkspace(); const form = useForm({ resolver: zodResolver(ContentUploadSchema), defaultValues: { title: '', contentType: 'blog_post', contentUrl: '', tags: [], customInstructions: '', }, }); const onSubmit = (data: ContentUploadFormData) => { startTransition(async () => { const formData = new FormData(); // Add all form fields to FormData formData.append('title', data.title); formData.append('contentType', data.contentType); formData.append('accountId', account.id); if (uploadMode === 'url' && data.contentUrl) { formData.append('contentUrl', data.contentUrl); } if (uploadMode === 'file' && data.file) { formData.append('file', data.file); } if (data.customInstructions) { formData.append('customInstructions', data.customInstructions); } if (data.tags && data.tags.length > 0) { formData.append('tags', JSON.stringify(data.tags)); } const promise = uploadContentAction({ title: data.title, contentType: data.contentType, contentUrl: uploadMode === 'url' ? data.contentUrl : '', file: uploadMode === 'file' ? data.file : undefined, customInstructions: data.customInstructions, tags: data.tags, accountId: account.id, }); toast.promise(promise, { loading: ( <Trans i18nKey="common:uploading" defaults="Uploading content..." /> ), success: (result) => { if (result.success) { form.reset(); router.push(`/home/${account.slug}`); return ( <Trans i18nKey="upload:success" defaults="Content uploaded successfully!" /> ); } throw new Error(result.error); }, error: (error) => error.message || ( <Trans i18nKey="upload:error" defaults="Upload failed" /> ), }); }); }; return ( <div className="mx-auto max-w-2xl"> <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> {/* Title Field */} <FormField control={form.control} name="title" render={({ field }) => ( <FormItem> <FormLabel> <Trans i18nKey="upload:titleLabel" defaults="Content Title" /> </FormLabel> <FormControl> <Input placeholder="Enter a descriptive title for your content" data-test="content-title-input" {...field} /> </FormControl> <FormDescription> <Trans i18nKey="upload:titleDescription" defaults="Give your content a clear, descriptive title" /> </FormDescription> <FormMessage /> </FormItem> )} /> {/* Content Type Field */} <FormField control={form.control} name="contentType" render={({ field }) => ( <FormItem> <FormLabel> <Trans i18nKey="upload:contentTypeLabel" defaults="Content Type" /> </FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value} > <FormControl> <SelectTrigger data-test="content-type-select"> <SelectValue placeholder="Select content type" /> </SelectTrigger> </FormControl> <SelectContent> {contentTypeOptions.map((option) => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> ))} </SelectContent> </Select> <FormDescription> <Trans i18nKey="upload:contentTypeDescription" defaults="Choose the type of content you're uploading" /> </FormDescription> <FormMessage /> </FormItem> )} /> {/* Upload Mode Selection */} <div className="space-y-4"> <Label> <Trans i18nKey="upload:sourceLabel" defaults="Content Source" /> </Label> <div className="flex gap-4"> <Button type="button" variant={uploadMode === 'url' ? 'default' : 'outline'} onClick={() => setUploadMode('url')} data-test="url-mode-button" > <Trans i18nKey="upload:fromUrl" defaults="From URL" /> </Button> <Button type="button" variant={uploadMode === 'file' ? 'default' : 'outline'} onClick={() => setUploadMode('file')} data-test="file-mode-button" > <Trans i18nKey="upload:uploadFile" defaults="Upload File" /> </Button> </div> </div> <Separator /> {/* URL Input */} <If condition={uploadMode === 'url'}> <FormField control={form.control} name="contentUrl" render={({ field }) => ( <FormItem> <FormLabel> <Trans i18nKey="upload:urlLabel" defaults="Content URL" /> </FormLabel> <FormControl> <Input type="url" placeholder="https://example.com/your-content" data-test="content-url-input" {...field} /> </FormControl> <FormDescription> <Trans i18nKey="upload:urlDescription" defaults="Enter the URL of the content you want to repurpose" /> </FormDescription> <FormMessage /> </FormItem> )} /> </If> {/* File Upload */} <If condition={uploadMode === 'file'}> <FormField control={form.control} name="file" render={({ field: { onChange, value, ...field } }) => ( <FormItem> <FormLabel> <Trans i18nKey="upload:fileLabel" defaults="Upload File" /> </FormLabel> <FormControl> <Input type="file" accept=".md,.txt,text/markdown,text/plain" data-test="file-upload-input" onChange={(e) => { const file = e.target.files?.[0]; onChange(file); }} {...field} /> </FormControl> <FormDescription> <Trans i18nKey="upload:fileDescription" defaults="Upload a markdown (.md) or text (.txt) file (max 5MB)" /> </FormDescription> <FormMessage /> </FormItem> )} /> </If> {/* Custom Instructions */} <FormField control={form.control} name="customInstructions" render={({ field }) => ( <FormItem> <FormLabel> <Trans i18nKey="upload:instructionsLabel" defaults="Custom Instructions" /> </FormLabel> <FormControl> <Textarea placeholder="Any specific instructions for repurposing this content..." className="min-h-[100px]" data-test="custom-instructions-input" {...field} /> </FormControl> <FormDescription> <Trans i18nKey="upload:instructionsDescription" defaults="Optional: Provide specific instructions for how you'd like this content repurposed" /> </FormDescription> <FormMessage /> </FormItem> )} /> {/* Submit Button */} <div className="flex justify-end gap-3"> <Button type="button" variant="outline" onClick={() => router.back()} disabled={isPending} > <Trans i18nKey="common:cancel" defaults="Cancel" /> </Button> <Button type="submit" disabled={isPending} data-test="upload-submit-button" > <If condition={isPending}> <Spinner className="mr-2 h-4 w-4" /> <Trans i18nKey="upload:uploading" defaults="Uploading..." /> </If> <If condition={!isPending}> <Trans i18nKey="upload:uploadButton" defaults="Upload Content" /> </If> </Button> </div> </form> </Form> </div> );}
In addition, it added the Server Actions to upload the content:
'use server';import { z } from 'zod';import { enhanceAction } from '@kit/next/actions';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { ContentUploadSchema } from '../schema/content-upload.schema';export const uploadContentAction = enhanceAction( async function uploadContent(data, user) { const client = getSupabaseServerClient(); let fileUrl: string | null = null; let fileName: string | null = null; let fileSize: number | null = null; let fileType: string | null = null; let originalText = ''; let uploadData: any = null; try { // Get account ID from team workspace context first const accountId = data.accountId; if (!accountId) { throw new Error('Account ID is required'); } // Verify user has access to this account const { data: membership } = await client .from('accounts_memberships') .select('account_id') .eq('account_id', accountId) .eq('user_id', user.id) .single(); if (!membership) { throw new Error('You do not have access to this account'); } // Handle file upload if provided if (data.file) { const file = data.file; fileName = file.name; fileSize = file.size; fileType = file.type; if (!fileName) { throw new Error('File name is required'); } // Generate unique file path with account ID (not user ID) for proper RLS const fileExt = fileName.split('.').pop(); const filePath = `content-uploads/${accountId}/${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`; // Upload file to Supabase Storage const uploadResult = await client.storage .from('content-files') .upload(filePath, file); uploadData = uploadResult.data; const uploadError = uploadResult.error; if (uploadError) { throw new Error(`File upload failed: ${uploadError.message}`); } // Get public URL const { data: urlData } = client.storage .from('content-files') .getPublicUrl(filePath); fileUrl = urlData.publicUrl; // Read file content const fileContent = await file.text(); originalText = fileContent; } // Handle URL content if provided (without file) if (data.contentUrl && !data.file) { try { const response = await fetch(data.contentUrl); if (!response.ok) { throw new Error( `Failed to fetch content from URL: ${response.statusText}`, ); } originalText = await response.text(); } catch (error) { throw new Error( `Unable to fetch content from URL: ${error instanceof Error ? error.message : 'Unknown error'}`, ); } } if (!originalText.trim()) { throw new Error( 'No content found. Please provide either a valid URL or upload a file with content.', ); } // Insert original content record const { data: contentRecord, error: insertError } = await client .from('original_content') .insert({ account_id: accountId, title: data.title, content_type: data.contentType, original_text: originalText, file_url: fileUrl, file_name: fileName, file_size: fileSize, file_type: fileType, status: 'uploaded', metadata: { source: data.file ? 'file_upload' : 'url', original_url: data.contentUrl || null, custom_instructions: data.customInstructions || null, }, tags: data.tags || [], created_by: user.id, }) .select() .single(); if (insertError) { // Clean up uploaded file if database insert fails if (fileUrl && uploadData?.path) { await client.storage.from('content-files').remove([uploadData.path]); } throw new Error(`Failed to save content: ${insertError.message}`); } return { success: true, data: { contentId: contentRecord.id, title: contentRecord.title, contentType: contentRecord.content_type, wordCount: contentRecord.word_count, }, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Upload failed', }; } }, { auth: true, schema: ContentUploadSchema.and( z.object({ accountId: z.string().uuid(), }), ), },);
Validation issues
In general the ouput was very very good. Claude Code made two mistakes, both related to Zod validation.
- It added a generic to
useForm
which is not needed and broke types inference. - It uses
ZodType.extend
which cannot be done after usingrefine
to create the schema.
It was a quick manual fix, but it's important to note that the AI Agent is not perfect and it will make mistakes. It's important to be aware of this and to be able to fix it manually.
In addition, there were two minor issues:
- Claude Code forgot to add the page to the sidebar; actually, it was a mistake on my side, which I should have added to the requirements. I added this manually since it's so simple and easy to do.
- Claude Code ignored my request to not implement the Server Action yet and just implemented it.
Vulnerability: Supabase Storage rules were vulnerabilities
Claude Code made two mistakes with the Supabase Storage rules:
- It allows any authenticated user to view files (instead of controlling access to only the team that owns it) and does not scope control access by team account ID - instead it does so by user ID. This disallows team members from seeing files
- It allows any authenticated user to delete files (instead of controlling access to only the team that owns it) and does not scope control access by team account ID - instead it does so by user ID. This disallows team members from deleting files
This is a very serious issue and it's a security vulnerability. We need to fix it.
Here's how I asked Claude Code to fix it:
There are security issues with the storage RLS. They allow any authenticated user to view files (instead of controlling access to only the team that owns it) and does not scope control access by team account ID - instead it does so by user ID. This disallows team members from seeing files
Of course, this allowed me to tighten the CLAUDE.MD rules, so this should not happen again. However, as usual, please watch out for these issues and fix them manually or ask Claude Code to review its own work and fix the issues.
Claude added the fixed Storage rules to the existing 16-storage.sql
file:
-- Content Files for AI Content RepurposerAdd commentMore actionsinsert into storage.buckets (id, name, PUBLIC)values ('content-files', 'content-files', false); More actions-- Helper function to extract account ID from content file pathcreate or replace function kit.get_account_id_from_content_path(file_path text)returns uuidlanguage plpgsqlsecurity definerset search_path to ''as $$declare account_uuid uuid;begin -- Extract account ID from path like: content-uploads/{account_id}/{file} -- Split by '/' and get the second part (index 2 in array, since arrays are 1-indexed) select (string_to_array(file_path, '/'))[2]::uuid into account_uuid; return account_uuid;exception when others then return null;end;$$;grant execute on function kit.get_account_id_from_content_path(text) to authenticated, service_role;-- RLS policies for storage bucket content-files using team account access controlcreate policy content_files_select on storage.objects for select using ( bucket_id = 'content-files' and auth.role() = 'authenticated' and public.has_role_on_account(kit.get_account_id_from_content_path(name)));create policy content_files_insert on storage.objects for insert with check ( bucket_id = 'content-files' and auth.role() = 'authenticated' and starts_with(name, 'content-uploads/') and public.has_role_on_account(kit.get_account_id_from_content_path(name)));create policy content_files_update on storage.objects for update using ( bucket_id = 'content-files' and auth.role() = 'authenticated' and public.has_role_on_account(kit.get_account_id_from_content_path(name))) with check ( bucket_id = 'content-files' and auth.role() = 'authenticated' and public.has_role_on_account(kit.get_account_id_from_content_path(name)));create policy content_files_delete on storage.objects for delete using ( bucket_id = 'content-files' and auth.role() = 'authenticated' and public.has_role_on_account(kit.get_account_id_from_content_path(name)));
Final result of the content upload page
Here's the final result after I fixed the issues manually:

Now, the form doesn't really look that great, but it's a good starting point and we can improve it later.
Displaying the uploaded content in the team account context
Now, let's move on to the next step. We want to display the uploaded content in the team account context home page (eg. /home/<account>
).
I asked Claude Code to create a new page at /home/[account]
that displays the uploaded content.
Replace the existing dashboard at
/home/[account]
with a content display page. Show uploaded content list for the current team account, fetch data using Makerkit's server component patterns withgetSupabaseServerClient()
. Follow their component organization in_components/
directory.
Claude Code generated near-perfect code for this, with the only issue being it did not reuse the generated Supabase types, and instead created its own types. I fixed this manually.
Here's the final result for the empty state:

and here's the result for the content list when items are present:

Let's go through the changes that Claude Code made to the codebase:
The page to display the content item details
import Link from 'next/link';import { Plus } from 'lucide-react';import { Button } from '@kit/ui/button';import { Trans } from '@kit/ui/trans';interface ContentHeaderProps { accountSlug: string; totalCount: number;}export function ContentHeader({ accountSlug, totalCount }: ContentHeaderProps) { return ( <div className="flex items-center justify-between"> <div> <h2 className="text-2xl font-semibold tracking-tight"> <Trans i18nKey="content:listTitle" defaults="Content Library" /> </h2> <p className="text-muted-foreground"> <Trans i18nKey="content:listDescription" values={{ count: totalCount }} defaults="Manage and repurpose your content" /> </p> </div> <Button asChild> <Link href={`/home/${accountSlug}/upload`}> <Plus className="mr-2 h-4 w-4" /> <Trans i18nKey="content:uploadNew" defaults="Upload Content" /> </Link> </Button> </div> );}
Below is the content list component:
import Link from 'next/link';import { formatDistanceToNow } from 'date-fns';import { Clock, FileText, Globe, User } from 'lucide-react';import { Badge } from '@kit/ui/badge';import { Button } from '@kit/ui/button';import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';import { If } from '@kit/ui/if';import { Trans } from '@kit/ui/trans';import type { ContentItem } from '../_lib/server/content.loader';interface ContentListProps { content: ContentItem[]; accountSlug: string;}export function ContentList({ content, accountSlug }: ContentListProps) { if (content.length === 0) { return <EmptyState accountSlug={accountSlug} />; } return ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> {content.map((item) => ( <ContentCard key={item.id} content={item} accountSlug={accountSlug} /> ))} </div> );}function ContentCard({ content, accountSlug,}: { content: ContentItem; accountSlug: string;}) { const isFromFile = content.metadata?.source === 'file_upload'; const statusColor = getStatusColor(content.status); return ( <Card className="group transition-all hover:shadow-md"> <CardHeader className="pb-3"> <div className="flex items-start justify-between"> <div className="flex items-center gap-2"> {isFromFile ? ( <FileText className="text-muted-foreground h-4 w-4" /> ) : ( <Globe className="text-muted-foreground h-4 w-4" /> )} <Badge variant="outline" className="text-xs"> {formatContentType(content.content_type)} </Badge> </div> <Badge variant={statusColor} className="text-xs"> {formatStatus(content.status)} </Badge> </div> <CardTitle className="line-clamp-2 text-base leading-tight"> {content.title} </CardTitle> </CardHeader> <CardContent className="pt-0"> <div className="space-y-3"> {/* Content Info */} <div className="text-muted-foreground flex items-center gap-4 text-sm"> <If condition={!!content.word_count}> <div className="flex items-center gap-1"> <FileText className="h-3 w-3" /> <span>{content.word_count} words</span> </div> </If> <div className="flex items-center gap-1"> <Clock className="h-3 w-3" /> <span> {formatDistanceToNow(new Date(content.created_at), { addSuffix: true, })} </span> </div> </div> {/* File Name */} <If condition={!!content.file_name}> <div className="text-muted-foreground text-sm"> <span className="font-medium">File:</span> {content.file_name} </div> </If> {/* Tags */} <If condition={content.tags && content.tags.length > 0}> <div className="flex flex-wrap gap-1"> {content.tags.slice(0, 3).map((tag, index) => ( <Badge key={index} variant="secondary" className="text-xs"> {tag} </Badge> ))} <If condition={content.tags.length > 3}> <Badge variant="secondary" className="text-xs"> +{content.tags.length - 3} </Badge> </If> </div> </If> {/* Actions */} <div className="flex gap-2 pt-2"> <Button asChild variant="outline" size="sm" className="flex-1"> <Link href={`/home/${accountSlug}/content/${content.id}`}> <Trans i18nKey="content:viewDetails" defaults="View Details" /> </Link> </Button> <If condition={content.status === 'uploaded'}> <Button asChild size="sm" className="flex-1"> <Link href={`/home/${accountSlug}/content/${content.id}/repurpose`} > <Trans i18nKey="content:repurpose" defaults="Repurpose" /> </Link> </Button> </If> </div> </div> </CardContent> </Card> );}function EmptyState({ accountSlug }: { accountSlug: string }) { return ( <div className="flex flex-col items-center justify-center py-12 text-center"> <div className="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"> <FileText className="text-muted-foreground h-8 w-8" /> </div> <h3 className="mb-2 text-lg font-semibold"> <Trans i18nKey="content:noContentTitle" defaults="No content yet" /> </h3> <p className="text-muted-foreground mb-6 max-w-sm"> <Trans i18nKey="content:noContentDescription" defaults="Upload your first piece of content to start repurposing it for different platforms." /> </p> <Button asChild> <Link href={`/home/${accountSlug}/upload`}> <Trans i18nKey="content:uploadFirst" defaults="Upload Content" /> </Link> </Button> </div> );}function formatContentType(type: string): string { const typeMap: Record<string, string> = { blog_post: 'Blog Post', article: 'Article', social_media_post: 'Social Post', newsletter: 'Newsletter', video_script: 'Video Script', podcast_script: 'Podcast Script', }; return typeMap[type] || type;}function formatStatus(status: string): string { const statusMap: Record<string, string> = { uploaded: 'Ready', processing: 'Processing', completed: 'Completed', failed: 'Failed', }; return statusMap[status] || status;}function getStatusColor( status: string,): 'default' | 'secondary' | 'destructive' | 'outline' { const colorMap: Record< string, 'default' | 'secondary' | 'destructive' | 'outline' > = { uploaded: 'secondary', processing: 'default', completed: 'outline', failed: 'destructive', }; return colorMap[status] || 'outline';}
Below is the content loader that the server component uses to fetch the content list:
import 'server-only';import { cache } from 'react';import { Tables } from '@kit/supabase/database';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';export type ContentItem = Tables<'original_content'>;export type ContentListData = { content: ContentItem[]; count: number; user: Awaited<ReturnType<typeof requireUserInServerComponent>>;};/** * Load content for the team account. * This function is cached so that the data is only fetched once per request. */export const loadTeamContent = cache(async (accountId: string) => { const client = getSupabaseServerClient(); const user = await requireUserInServerComponent(); // Verify user has access to this account const { data: membership, error: membershipError } = await client .from('accounts_memberships') .select('account_id') .eq('account_id', accountId) .eq('user_id', user.id) .single(); if (membershipError || !membership) { throw new Error('You do not have access to this account'); } // Fetch content for the account const { data: content, error: contentError, count, } = await client .from('original_content') .select( ` id, title, content_type, status, word_count, file_name, tags, created_at, created_by, metadata `, { count: 'exact' }, ) .eq('account_id', accountId) .order('created_at', { ascending: false }); if (contentError) { throw new Error(`Failed to load content: ${contentError.message}`); } return { content: content || [], count: count || 0, user, };});
The page to display the content list
With the components in place, we can now create the page to display the content list, which replaces the default dashboard that Makerkit includes by default:
import { use } from 'react';import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { PageBody } from '@kit/ui/page';import { Trans } from '@kit/ui/trans';import { createI18nServerInstance } from '~/lib/i18n/i18n.server';import { withI18n } from '~/lib/i18n/with-i18n';import { ContentHeader } from './_components/content-header';import { ContentList } from './_components/content-list';import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header';import { loadTeamContent } from './_lib/server/content.loader';import { loadTeamWorkspace } from './_lib/server/team-account-workspace.loader';interface TeamAccountHomePageProps { params: Promise<{ account: string }>;}export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); const title = i18n.t('content:pageTitle', { defaultValue: 'Content Library', }); return { title, };};async function TeamAccountHomePage({ params }: TeamAccountHomePageProps) { const { account: accountSlug } = await params; // Load workspace to get account ID const workspace = await loadTeamWorkspace(accountSlug); const accountId = workspace.account.id; // Load content for this account const contentData = await loadTeamContent(accountId); return ( <> <TeamAccountLayoutPageHeader account={accountSlug} title={ <Trans i18nKey={'content:pageTitle'} defaults="Content Library" /> } description={<AppBreadcrumbs />} /> <PageBody> <div className="space-y-6"> <ContentHeader accountSlug={accountSlug} totalCount={contentData.count} /> <ContentList content={contentData.content} accountSlug={accountSlug} /> </div> </PageBody> </> );}export default withI18n(TeamAccountHomePage);
Keeping the codebase clean every step of the way
As we go through the codebase, we need to keep the codebase clean and issue-free.
💡 Pro Tip: between each prompt, run the typecheker and linter to keep the codebase clean and issue-free.
pnpm run typecheckpnpm run lint:fix
If these fail, give Claude Code a chance to fix the issues by pasting the output of the typechecker and linter into the next prompt and asking it to fix the issues.
💡 You can ask Claude this question to check if the code is clean and issue-free:
Let's do some sanity check. Run the typechecking command and fix the errors you find.
Content Detail Page
Let's add a content detail page so that users can view the details of a content item.
I asked Claude Code to create a new page at /home/[account]/content/[contentId]
that displays the details of a content item.
We can now create the dedicated page to the content item and display releveant information.
I was quite light on the details, so Claude Code generated a very basic page that displays the content item details.
Here's the final result:

The HTML page source is displayed raw, which isn't great to look at, but perhaps we can improve it later.
Claude also added various actions that can be performed on the content item, but we will handle them in the next step. Right now I am super interested in the AI integration, so let's move on to that.
Let's go through the changes that Claude Code made to the codebase:
The content actions component
Below is the content actions component:
import Link from 'next/link';import { Edit, Trash2, Zap, Download, ExternalLink } from 'lucide-react';import { Button } from '@kit/ui/button';import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';import { Trans } from '@kit/ui/trans';import { If } from '@kit/ui/if';import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,} from '@kit/ui/alert-dialog';import type { ContentDetail } from '../_lib/server/content-detail.loader';interface ContentActionsProps { content: ContentDetail; accountSlug: string; canEdit: boolean;}export function ContentActions({ content, accountSlug, canEdit }: ContentActionsProps) { const canRepurpose = content.status === 'uploaded' || content.status === 'completed'; const hasFile = !!content.file_url; const hasExternalUrl = !!content.metadata?.original_url; return ( <Card> <CardHeader> <CardTitle className="text-lg"> <Trans i18nKey="content:actions" defaults="Actions" /> </CardTitle> </CardHeader> <CardContent> <div className="flex flex-col gap-3"> {/* Primary Actions */} <If condition={canRepurpose}> <Button asChild className="w-full"> <Link href={`/home/${accountSlug}/content/${content.id}/repurpose`}> <Zap className="mr-2 h-4 w-4" /> <Trans i18nKey="content:repurposeContent" defaults="Repurpose Content" /> </Link> </Button> </If> {/* View Original Source */} <If condition={hasExternalUrl}> <Button asChild variant="outline" className="w-full"> <a href={content.metadata.original_url} target="_blank" rel="noopener noreferrer" > <ExternalLink className="mr-2 h-4 w-4" /> <Trans i18nKey="content:viewOriginal" defaults="View Original" /> </a> </Button> </If> {/* Download File */} <If condition={hasFile}> <Button asChild variant="outline" className="w-full"> <a href={content.file_url} download={content.file_name} target="_blank" rel="noopener noreferrer" > <Download className="mr-2 h-4 w-4" /> <Trans i18nKey="content:downloadFile" defaults="Download File" /> </a> </Button> </If> {/* Edit Actions (only for content creators or admins) */} <If condition={canEdit}> <> <Button asChild variant="outline" className="w-full"> <Link href={`/home/${accountSlug}/content/${content.id}/edit`}> <Edit className="mr-2 h-4 w-4" /> <Trans i18nKey="content:editContent" defaults="Edit Content" /> </Link> </Button> {/* Delete Action */} <AlertDialog> <AlertDialogTrigger asChild> <Button variant="destructive" className="w-full"> <Trash2 className="mr-2 h-4 w-4" /> <Trans i18nKey="content:deleteContent" defaults="Delete Content" /> </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> <Trans i18nKey="content:deleteConfirmTitle" defaults="Delete Content" /> </AlertDialogTitle> <AlertDialogDescription> <Trans i18nKey="content:deleteConfirmDescription" defaults="Are you sure you want to delete this content? This action cannot be undone and will also delete all associated variations and processing jobs." /> </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel> <Trans i18nKey="common:cancel" defaults="Cancel" /> </AlertDialogCancel> <AlertDialogAction variant="destructive"> <Trans i18nKey="content:deleteConfirm" defaults="Delete" /> </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </> </If> </div> {/* Status Messages */} <div className="mt-4 space-y-2"> <If condition={content.status === 'processing'}> <div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-950 dark:text-blue-300"> <Trans i18nKey="content:processingMessage" defaults="This content is currently being processed. Repurposing will be available once processing is complete." /> </div> </If> <If condition={content.status === 'failed'}> <div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950 dark:text-red-300"> <Trans i18nKey="content:failedMessage" defaults="Processing failed for this content. You can try uploading it again or contact support for assistance." /> </div> </If> <If condition={!canEdit}> <div className="rounded-md bg-gray-50 p-3 text-sm text-gray-600 dark:bg-gray-900 dark:text-gray-400"> <Trans i18nKey="content:editPermissionMessage" defaults="Only the content creator or team admins can edit or delete this content." /> </div> </If> </div> </CardContent> </Card> );}
The content metadata component
Below is the content metadata component:
import { formatDistanceToNow } from 'date-fns';import { Calendar, FileText, Tag, User, Clock } from 'lucide-react';import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';import { Badge } from '@kit/ui/badge';import { Trans } from '@kit/ui/trans';import { If } from '@kit/ui/if';import type { ContentDetail } from '../_lib/server/content-detail.loader';interface ContentMetadataProps { content: ContentDetail;}export function ContentMetadata({ content }: ContentMetadataProps) { return ( <Card> <CardHeader> <CardTitle className="text-lg"> <Trans i18nKey="content:metadata" defaults="Content Details" /> </CardTitle> </CardHeader> <CardContent> <div className="space-y-4"> {/* Status */} <div className="flex items-center gap-2"> <FileText className="h-4 w-4 text-muted-foreground" /> <span className="text-sm font-medium"> <Trans i18nKey="content:status" defaults="Status:" /> </span> <Badge variant={getStatusColor(content.status)}> {formatStatus(content.status)} </Badge> </div> {/* Created */} <div className="flex items-center gap-2"> <Calendar className="h-4 w-4 text-muted-foreground" /> <span className="text-sm font-medium"> <Trans i18nKey="content:created" defaults="Created:" /> </span> <span className="text-sm text-muted-foreground"> {formatDistanceToNow(new Date(content.created_at), { addSuffix: true })} </span> </div> {/* Updated */} <If condition={content.updated_at !== content.created_at}> <div className="flex items-center gap-2"> <Clock className="h-4 w-4 text-muted-foreground" /> <span className="text-sm font-medium"> <Trans i18nKey="content:updated" defaults="Updated:" /> </span> <span className="text-sm text-muted-foreground"> {formatDistanceToNow(new Date(content.updated_at), { addSuffix: true })} </span> </div> </If> {/* Word Count */} <If condition={!!content.word_count}> <div className="flex items-center gap-2"> <FileText className="h-4 w-4 text-muted-foreground" /> <span className="text-sm font-medium"> <Trans i18nKey="content:wordCount" defaults="Word Count:" /> </span> <span className="text-sm text-muted-foreground"> {content.word_count?.toLocaleString()} words </span> </div> </If> {/* Tags */} <If condition={content.tags && content.tags.length > 0}> <div className="space-y-2"> <div className="flex items-center gap-2"> <Tag className="h-4 w-4 text-muted-foreground" /> <span className="text-sm font-medium"> <Trans i18nKey="content:tags" defaults="Tags:" /> </span> </div> <div className="flex flex-wrap gap-1"> {content.tags.map((tag, index) => ( <Badge key={index} variant="secondary" className="text-xs"> {tag} </Badge> ))} </div> </div> </If> {/* Processing Jobs Count */} <If condition={content.processing_jobs && content.processing_jobs.length > 0}> <div className="flex items-center gap-2"> <FileText className="h-4 w-4 text-muted-foreground" /> <span className="text-sm font-medium"> <Trans i18nKey="content:processingJobs" defaults="Processing Jobs:" /> </span> <span className="text-sm text-muted-foreground"> {content.processing_jobs.length} job{content.processing_jobs.length !== 1 ? 's' : ''} </span> </div> </If> {/* Variations Count */} <If condition={content.variations && content.variations.length > 0}> <div className="flex items-center gap-2"> <FileText className="h-4 w-4 text-muted-foreground" /> <span className="text-sm font-medium"> <Trans i18nKey="content:variations" defaults="Variations:" /> </span> <span className="text-sm text-muted-foreground"> {content.variations.length} variation{content.variations.length !== 1 ? 's' : ''} </span> </div> </If> </div> </CardContent> </Card> );}function formatStatus(status: string): string { const statusMap: Record<string, string> = { uploaded: 'Ready', processing: 'Processing', completed: 'Completed', failed: 'Failed', }; return statusMap[status] || status;}function getStatusColor(status: string): 'default' | 'secondary' | 'destructive' | 'outline' { const colorMap: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { uploaded: 'secondary', processing: 'default', completed: 'outline', failed: 'destructive', }; return colorMap[status] || 'outline';}
The content preview component
Below is the content preview component:
import { Badge } from '@kit/ui/badge';import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';import { If } from '@kit/ui/if';import { ScrollArea } from '@kit/ui/scroll-area';import { Trans } from '@kit/ui/trans';import type { ContentDetail } from '../_lib/server/content-detail.loader';interface ContentPreviewProps { content: ContentDetail;}export function ContentPreview({ content }: ContentPreviewProps) { const maxPreviewLength = 1000; // Show first 1000 characters const isLongContent = content.original_text.length > maxPreviewLength; const previewText = isLongContent ? content.original_text.substring(0, maxPreviewLength) + '...' : content.original_text; return ( <Card> <CardHeader> <div className="flex items-center justify-between"> <CardTitle className="text-lg"> <Trans i18nKey="content:originalContent" defaults="Original Content" /> </CardTitle> <div className="flex items-center gap-2"> <Badge variant="outline"> {formatContentType(content.content_type)} </Badge> <If condition={!!content.word_count}> <Badge variant="secondary">{content.word_count} words</Badge> </If> </div> </div> </CardHeader> <CardContent> <div className="space-y-4"> {/* Content Text Preview */} <div className="relative"> <ScrollArea className="h-[300px] w-full rounded-md border p-4"> <div className="font-mono text-sm whitespace-pre-wrap"> {previewText} </div> </ScrollArea> <If condition={isLongContent}> <div className="from-background absolute right-0 bottom-0 left-0 h-8 bg-gradient-to-t to-transparent" /> </If> </div> {/* File Information */} <If condition={!!content.file_name}> <div className="bg-muted rounded-md p-3"> <div className="text-sm"> <span className="font-medium"> <Trans i18nKey="content:sourceFile" defaults="Source File:" /> </span>{' '} {content.file_name} </div> {content.file_size && ( <div className="text-muted-foreground mt-1 text-xs"> {formatFileSize(content.file_size)} </div> )} </div> </If> {/* URL Source */} <If condition={!!content.metadata?.original_url}> <div className="bg-muted rounded-md p-3"> <div className="text-sm"> <span className="font-medium"> <Trans i18nKey="content:sourceUrl" defaults="Source URL:" /> </span>{' '} <a href={content.metadata.original_url} target="_blank" rel="noopener noreferrer" className="text-primary break-all hover:underline" > {content.metadata.original_url} </a> </div> </div> </If> {/* Custom Instructions */} <If condition={!!content.metadata?.custom_instructions}> <div className="bg-muted rounded-md p-3"> <div className="text-sm"> <span className="font-medium"> <Trans i18nKey="content:customInstructions" defaults="Custom Instructions:" /> </span> </div> <div className="text-muted-foreground mt-1 text-sm"> {content.metadata.custom_instructions} </div> </div> </If> </div> </CardContent> </Card> );}function formatContentType(type: string): string { const typeMap: Record<string, string> = { blog_post: 'Blog Post', article: 'Article', social_media_post: 'Social Post', newsletter: 'Newsletter', video_script: 'Video Script', podcast_script: 'Podcast Script', }; return typeMap[type] || type;}function formatFileSize(bytes: number): string { const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`;}
The content detail loader
Now, we define the content detail loader that the server component uses to fetch the content detail:
import 'server-only';import { cache } from 'react';import { notFound } from 'next/navigation';import { Tables } from '@kit/supabase/database';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';export type ContentDetail = Tables<'original_content'> & { processing_jobs?: Array<{ id: string; status: string; target_platforms: string[]; credits_cost: number; created_at: string; error_message: string | null; }>; variations?: Array<{ id: string; platform: string; content_text: string; is_approved: boolean; is_published: boolean; created_at: string; }>;};export type ContentDetailData = { content: ContentDetail; user: Awaited<ReturnType<typeof requireUserInServerComponent>>; canEdit: boolean;};/** * Load detailed information for a specific content item. * This function is cached so that the data is only fetched once per request. */export const loadContentDetail = cache( async (accountId: string, contentId: string): Promise<ContentDetailData> => { const client = getSupabaseServerClient(); const user = await requireUserInServerComponent(); // Fetch the content item with full details const { data: content, error: contentError } = await client .from('original_content') .select( ` * `, ) .eq('id', contentId) .eq('account_id', accountId) .single(); if (contentError || !content) { notFound(); } // Fetch related repurposing jobs if any const { data: jobs } = await client .from('repurposing_jobs') .select( ` id, status, target_platforms, credits_cost, created_at, error_message `, ) .eq('original_content_id', contentId) .order('created_at', { ascending: false }); // Fetch content variations if any const { data: variations } = await client .from('content_variations') .select( ` id, platform, content_text, is_approved, is_published, created_at `, ) .eq('original_content_id', contentId) .order('created_at', { ascending: false }); // Determine if user can edit (owner or admin) const canEdit = content.created_by === user.id; return { content: { ...content, processing_jobs: jobs || [], variations: variations || [], }, user, canEdit, }; },);
The content detail page
Finally, we create the content detail page that displays the content item details:
import { use } from 'react';import Link from 'next/link';import { ArrowLeft } from 'lucide-react';import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { Button } from '@kit/ui/button';import { PageBody } from '@kit/ui/page';import { Trans } from '@kit/ui/trans';import { createI18nServerInstance } from '~/lib/i18n/i18n.server';import { withI18n } from '~/lib/i18n/with-i18n';import { TeamAccountLayoutPageHeader } from '../../_components/team-account-layout-page-header';import { loadTeamWorkspace } from '../../_lib/server/team-account-workspace.loader';import { ContentActions } from './_components/content-actions';import { ContentMetadata } from './_components/content-metadata';import { ContentPreview } from './_components/content-preview';import { loadContentDetail } from './_lib/server/content-detail.loader';interface ContentDetailPageProps { params: Promise<{ account: string; id: string }>;}export const generateMetadata = async ({ params }: ContentDetailPageProps) => { const { account: accountSlug, id: contentId } = await params; try { const workspace = await loadTeamWorkspace(accountSlug); const contentData = await loadContentDetail(workspace.account.id, contentId); const i18n = await createI18nServerInstance(); const title = i18n.t('content:detailPageTitle', { defaultValue: `${contentData.content.title} - Content Details`, title: contentData.content.title, }); return { title, }; } catch { const i18n = await createI18nServerInstance(); return { title: i18n.t('content:detailPageTitle', { defaultValue: 'Content Details' }), }; }};async function ContentDetailPage({ params }: ContentDetailPageProps) { const { account: accountSlug, id: contentId } = await params; // Load workspace to get account ID const workspace = await loadTeamWorkspace(accountSlug); const accountId = workspace.account.id; // Load content details const contentData = await loadContentDetail(accountId, contentId); return ( <> <TeamAccountLayoutPageHeader account={accountSlug} title={contentData.content.title} description={<AppBreadcrumbs />} /> <PageBody> <div className="space-y-6"> {/* Back Navigation */} <div className="flex items-center gap-4"> <Button asChild variant="ghost" size="sm"> <Link href={`/home/${accountSlug}`}> <ArrowLeft className="mr-2 h-4 w-4" /> <Trans i18nKey="common:back" defaults="Back" /> </Link> </Button> <div className="text-sm text-muted-foreground"> <Trans i18nKey="content:backToLibrary" defaults="Back to Content Library" /> </div> </div> {/* Main Content Layout */} <div className="grid gap-6 lg:grid-cols-3"> {/* Content Preview - Takes up 2/3 of the space */} <div className="lg:col-span-2"> <ContentPreview content={contentData.content} /> </div> {/* Sidebar - Metadata and Actions */} <div className="space-y-6"> <ContentMetadata content={contentData.content} /> <ContentActions content={contentData.content} accountSlug={accountSlug} canEdit={contentData.canEdit} /> </div> </div> {/* Processing Jobs Section (if any) */} {/* TODO: Add processing jobs display component */} {/* Variations Section (if any) */} {/* TODO: Add content variations display component */} </div> </PageBody> </> );}export default withI18n(ContentDetailPage);
That's it for now. We have a working content list and detail page!
In between this step and the next step, I asked Claude Code to find and fix all the type errors and linting issues in the codebase. So, there are a few changes between the code above and the code in the final result.
AI Integration 🤖
Next - we want to add the AI integration to the content detail page, and develop the actual value proposition of the SaaS: creating social media variations of the uploaded content.
I asked Claude Code to create a new service that takes the uploaded content and calls OpenAI API to generate social media variations:
Create an AI service that takes the uploaded content and calls OpenAI API to generate social media variations. Install the Open AI package in apps/web and use it to call the OpenAI API. Make sure to update the user usage and credits tracking. We will display the results in another page and list them in the content detail page.
And here's the final result, which looks quite good:

I had to fix a few minor issues manually, but 99% of the code was generated by Claude Code.
Aside from some required changes to clean up type issues and errors, Claude Code also added the following features:
Repurpose schema
The Repurpose schema is a Zod schema that validates the data submitted by the user when they want to repurpose the content.
import { z } from 'zod';export const RepurposeFormSchema = z.object({ targetPlatforms: z.array(z.enum([ 'twitter', 'linkedin', 'instagram', 'facebook', 'tiktok', 'youtube', 'blog', 'newsletter' ])).min(1, 'Select at least one platform'), tone: z.enum(['professional', 'casual', 'friendly', 'authoritative', 'playful', 'inspirational']) .optional(), customInstructions: z.string() .max(500, 'Instructions must be less than 500 characters') .optional(),});export type RepurposeFormData = z.infer<typeof RepurposeFormSchema>;export const PLATFORM_OPTIONS = [ { value: 'twitter', label: 'Twitter/X', description: 'Short, engaging posts with hashtags', maxLength: 280, icon: '𝕏' }, { value: 'linkedin', label: 'LinkedIn', description: 'Professional, business-focused content', maxLength: 3000, icon: '💼' }, { value: 'instagram', label: 'Instagram', description: 'Visual-friendly captions with hashtags', maxLength: 2200, icon: '📸' }, { value: 'facebook', label: 'Facebook', description: 'Community-focused, story-driven posts', maxLength: 63206, icon: '👥' }, { value: 'tiktok', label: 'TikTok', description: 'Trendy, creative content with hooks', maxLength: 2200, icon: '🎵' }, { value: 'youtube', label: 'YouTube', description: 'SEO-friendly descriptions and CTAs', maxLength: 5000, icon: '📺' }, { value: 'blog', label: 'Blog', description: 'Comprehensive, SEO-optimized content', maxLength: 10000, icon: '📝' }, { value: 'newsletter', label: 'Newsletter', description: 'Personal, value-driven content', maxLength: 5000, icon: '📧' },] as const;export const TONE_OPTIONS = [ { value: 'professional', label: 'Professional', description: 'Formal and business-appropriate' }, { value: 'casual', label: 'Casual', description: 'Relaxed and conversational' }, { value: 'friendly', label: 'Friendly', description: 'Warm and approachable' }, { value: 'authoritative', label: 'Authoritative', description: 'Expert and confident' }, { value: 'playful', label: 'Playful', description: 'Fun and lighthearted' }, { value: 'inspirational', label: 'Inspirational', description: 'Motivating and uplifting' },] as const;
Platform Selector
The Platform selector component is a simple component that allows the user to select the platform to repurpose the content to.
'use client';import { Check } from 'lucide-react';import { Badge } from '@kit/ui/badge';import { Card, CardContent } from '@kit/ui/card';import { Checkbox } from '@kit/ui/checkbox';import { If } from '@kit/ui/if';import { Trans } from '@kit/ui/trans';import { PLATFORM_OPTIONS } from '../_lib/schema/repurpose.schema';interface PlatformSelectorProps { selectedPlatforms: string[]; onPlatformsChange: (platforms: string[]) => void; creditsPerPlatform?: number;}export function PlatformSelector({ selectedPlatforms, onPlatformsChange, creditsPerPlatform = 1,}: PlatformSelectorProps) { const handlePlatformToggle = (platformValue: string) => { const newPlatforms = selectedPlatforms.includes(platformValue) ? selectedPlatforms.filter((p) => p !== platformValue) : [...selectedPlatforms, platformValue]; onPlatformsChange(newPlatforms); }; const totalCredits = selectedPlatforms.length * creditsPerPlatform; return ( <div className="space-y-4"> <div className="flex items-center justify-between"> <h3 className="text-lg font-medium"> <Trans i18nKey="repurpose:selectPlatforms" defaults="Select Target Platforms" /> </h3> <If condition={selectedPlatforms.length > 0}> <Badge variant="secondary"> {totalCredits} credit{totalCredits !== 1 ? 's' : ''} </Badge> </If> </div> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> {PLATFORM_OPTIONS.map((platform) => { const isSelected = selectedPlatforms.includes(platform.value); return ( <Card key={platform.value} className={`relative transition-all hover:shadow-md ${ isSelected ? 'ring-primary ring' : '' }`} > <CardContent className="p-4"> <label className="flex items-start space-x-3"> <Checkbox checked={isSelected} className="mt-1 cursor-pointer" onCheckedChange={() => handlePlatformToggle(platform.value)} /> <div className="flex-1 space-y-2"> <div className="flex items-center gap-2"> <span className="text-lg">{platform.icon}</span> <h4 className="font-medium">{platform.label}</h4> </div> <p className="text-muted-foreground text-sm"> {platform.description} </p> <div className="text-muted-foreground text-xs"> Max: {platform.maxLength.toLocaleString()} chars </div> </div> </label> <If condition={isSelected}> <div className="absolute top-2 right-2"> <div className="bg-primary text-primary-foreground flex h-5 w-5 items-center justify-center rounded-full"> <Check className="h-3 w-3" /> </div> </div> </If> </CardContent> </Card> ); })} </div> <If condition={selectedPlatforms.length === 0}> <p className="text-muted-foreground py-4 text-center text-sm"> <Trans i18nKey="repurpose:selectPlatformsHelper" defaults="Choose one or more platforms to repurpose your content for. Each platform costs 1 credit." /> </p> </If> </div> );}
And the form that allows the user to select the platforms and submit the repurposing job:
'use client';import { useTransition } from 'react';import { useRouter } from 'next/navigation';import { zodResolver } from '@hookform/resolvers/zod';import { useForm } from 'react-hook-form';import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';import { Button } from '@kit/ui/button';import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@kit/ui/form';import { If } from '@kit/ui/if';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@kit/ui/select';import { Separator } from '@kit/ui/separator';import { toast } from '@kit/ui/sonner';import { Spinner } from '@kit/ui/spinner';import { Textarea } from '@kit/ui/textarea';import { Trans } from '@kit/ui/trans';import type { ContentDetail } from '../../_lib/server/content-detail.loader';import { type RepurposeFormData, RepurposeFormSchema, TONE_OPTIONS,} from '../_lib/schema/repurpose.schema';import { repurposeContentAction } from '../_lib/server/server-actions';import { PlatformSelector } from './platform-selector';interface RepurposeFormProps { content: ContentDetail; accountSlug: string;}export function RepurposeForm({ content, accountSlug }: RepurposeFormProps) { const [isPending, startTransition] = useTransition(); const router = useRouter(); const { account } = useTeamAccountWorkspace(); const form = useForm<RepurposeFormData>({ resolver: zodResolver(RepurposeFormSchema), defaultValues: { targetPlatforms: [], tone: undefined, customInstructions: '', }, }); const selectedPlatforms = form.watch('targetPlatforms'); const creditsRequired = selectedPlatforms.length; const onSubmit = (data: RepurposeFormData) => { startTransition(async () => { const promise = repurposeContentAction({ contentId: content.id, accountId: account.id, targetPlatforms: data.targetPlatforms, customInstructions: data.customInstructions, tone: data.tone, }); toast.promise(promise, { loading: ( <Trans i18nKey="repurpose:processing" defaults="Processing your content with AI..." /> ), success: (result) => { if (result.success) { // Redirect to results page router.push( `/home/${accountSlug}/content/${content.id}/variations`, ); return ( <Trans i18nKey="repurpose:success" defaults="Content repurposed successfully!" /> ); } throw new Error(result.error); }, error: (error) => { return ( error.message || ( <Trans i18nKey="repurpose:error" defaults="Repurposing failed" /> ) ); }, }); }); }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> {/* Platform Selection */} <FormField control={form.control} name="targetPlatforms" render={({ field }) => ( <FormItem> <FormControl> <PlatformSelector selectedPlatforms={field.value} onPlatformsChange={field.onChange} creditsPerPlatform={1} /> </FormControl> <FormMessage /> </FormItem> )} /> <Separator /> {/* Tone Selection */} <FormField control={form.control} name="tone" render={({ field }) => ( <FormItem> <FormLabel> <Trans i18nKey="repurpose:tone" defaults="Tone" /> </FormLabel> <FormControl> <Select onValueChange={field.onChange} value={field.value}> <SelectTrigger> <SelectValue placeholder="Select a tone (optional)" /> </SelectTrigger> <SelectContent> {TONE_OPTIONS.map((tone) => ( <SelectItem key={tone.value} value={tone.value}> <div> <div className="font-medium">{tone.label}</div> <div className="text-muted-foreground text-xs"> {tone.description} </div> </div> </SelectItem> ))} </SelectContent> </Select> </FormControl> <FormDescription> <Trans i18nKey="repurpose:toneDescription" defaults="Choose the tone for your repurposed content. Leave blank to maintain the original tone." /> </FormDescription> <FormMessage /> </FormItem> )} /> {/* Custom Instructions */} <FormField control={form.control} name="customInstructions" render={({ field }) => ( <FormItem> <FormLabel> <Trans i18nKey="repurpose:customInstructions" defaults="Custom Instructions" /> </FormLabel> <FormControl> <Textarea placeholder="Any specific instructions for repurposing (optional)..." className="min-h-[100px]" {...field} /> </FormControl> <FormDescription> <Trans i18nKey="repurpose:instructionsDescription" defaults="Provide specific instructions for how you'd like your content adapted for each platform." /> </FormDescription> <FormMessage /> </FormItem> )} /> {/* Submit Section */} <div className="bg-muted/50 flex flex-col gap-4 rounded-lg border p-4"> <div className="flex items-center justify-between text-sm"> <span className="font-medium"> <Trans i18nKey="repurpose:creditsRequired" defaults="Credits Required:" /> </span> <span className="font-medium"> {creditsRequired} credit{creditsRequired !== 1 ? 's' : ''} </span> </div> <If condition={creditsRequired > 0}> <div className="text-muted-foreground text-xs"> <Trans i18nKey="repurpose:creditsExplanation" values={{ count: creditsRequired }} defaults="This will consume {count} credit{count, plural, one {} other {s}} from your account balance." /> </div> </If> <div className="flex justify-end gap-3"> <Button type="button" variant="outline" onClick={() => router.back()} disabled={isPending} > <Trans i18nKey="common:cancel" defaults="Cancel" /> </Button> <Button type="submit" disabled={isPending || creditsRequired === 0}> <If condition={isPending}> <Spinner className="mr-2 h-4 w-4" /> <Trans i18nKey="repurpose:processing" defaults="Processing..." /> </If> <If condition={!isPending}> <Trans i18nKey="repurpose:startProcessing" defaults="Start Repurposing" /> </If> </Button> </div> </div> </form> </Form> );}
Server Actions to handle the repurposing job
The server actions to handle the repurposing job are:
'use server';import { z } from 'zod';import { enhanceAction } from '@kit/next/actions';import { getLogger } from '@kit/shared/logger';import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { ContentRepurposerService, type SupportedPlatform,} from '~/lib/ai/content-repurposer.service';const RepurposeContentSchema = z.object({ contentId: z.string().uuid(), accountId: z.string().uuid(), targetPlatforms: z .array( z.enum([ 'twitter', 'linkedin', 'instagram', 'facebook', 'tiktok', 'youtube', 'blog', 'newsletter', ]), ) .min(1, 'At least one platform is required'), customInstructions: z.string().optional(), tone: z.string().optional(),});export const repurposeContentAction = enhanceAction( async function repurposeContent(data, user) { const client = getSupabaseServerClient(); const adminClient = getSupabaseServerAdminClient(); const logger = await getLogger(); const ctx = { name: 'repurposeContentAction', userId: user.id, contentId: data.contentId, accountId: data.accountId, platforms: data.targetPlatforms, }; logger.info(ctx, 'Starting content repurposing process'); try { const { data: content, error: contentError } = await client .from('original_content') .select('*') .eq('id', data.contentId) .eq('account_id', data.accountId) .single(); if (contentError || !content) { throw new Error('Content not found'); } if (content.status !== 'uploaded' && content.status !== 'completed') { throw new Error('Content is not ready for repurposing'); } // 3. Calculate credit cost (1 credit per platform) const creditsCost = data.targetPlatforms.length; // 4. Check and consume credits using admin client const consumeResult = await adminClient.rpc('consume_credits', { p_account_id: data.accountId, p_amount: creditsCost, p_job_id: '', // Will be updated after job creation p_description: `Content repurposing for ${data.targetPlatforms.length} platform(s)`, }); if (!consumeResult.data) { throw new Error( 'Insufficient credits. Please purchase more credits to continue.', ); } // 5. Create a repurposing job record const { data: job, error: jobError } = await client .from('repurposing_jobs') .insert({ account_id: data.accountId, original_content_id: data.contentId, target_platforms: data.targetPlatforms, custom_instructions: data.customInstructions, tone: data.tone, status: 'processing', credits_cost: creditsCost, created_by: user.id, }) .select() .single(); if (jobError || !job) { throw new Error('Failed to create repurposing job'); } logger.info({ ...ctx, jobId: job.id }, 'Created repurposing job'); // 6. Update the credit transaction with the job ID await adminClient .from('credit_transactions') .update({ job_id: job.id }) .eq('account_id', data.accountId) .eq('amount', -creditsCost) .eq('transaction_type', 'usage') .is('job_id', null) .order('created_at', { ascending: false }) .limit(1); // 7. Process with AI service try { const repurposerService = new ContentRepurposerService(); const result = await repurposerService.repurposeContent({ originalContent: content.original_text, contentType: content.content_type, targetPlatforms: data.targetPlatforms as SupportedPlatform[], customInstructions: data.customInstructions, tone: data.tone, }); if (!result.success) { throw new Error(result.error || 'AI processing failed'); } // 8. Save variations to database const variations = result.variations.map((variation) => ({ account_id: data.accountId, job_id: job.id, original_content_id: data.contentId, platform: variation.platform, content_text: variation.content, character_count: variation.characterCount, hashtags: variation.hashtags, mentions: variation.mentions, ai_confidence_score: variation.metadata.confidence, platform_metadata: { model: variation.metadata.model, tokens_used: variation.metadata.tokensUsed, }, })); const { error: variationsError } = await client .from('content_variations') .insert(variations); if (variationsError) { throw new Error('Failed to save content variations'); } // 9. Update job status to completed await client .from('repurposing_jobs') .update({ status: 'completed', processing_completed_at: new Date().toISOString(), ai_model_used: 'gpt-4o-mini', processing_duration_ms: result.processingTimeMs, tokens_consumed: result.totalTokensUsed, }) .eq('id', job.id); logger.info( { ...ctx, jobId: job.id, variationsCount: variations.length, tokensUsed: result.totalTokensUsed, processingTimeMs: result.processingTimeMs, }, 'Content repurposing completed successfully', ); return { success: true, data: { jobId: job.id, variationsCount: variations.length, tokensUsed: result.totalTokensUsed, processingTimeMs: result.processingTimeMs, }, }; } catch (aiError) { // Update job status to failed await client .from('repurposing_jobs') .update({ status: 'failed', error_message: aiError instanceof Error ? aiError.message : 'Unknown AI processing error', }) .eq('id', job.id); throw aiError; } } catch (error) { logger.error({ ...ctx, error }, 'Content repurposing failed'); return { success: false, error: error instanceof Error ? error.message : 'Repurposing failed', }; } }, { auth: true, schema: RepurposeContentSchema, },);
Repurpose Page
Finally, we create the page to display the repurpose form:
import { use } from 'react';import Link from 'next/link';import { ArrowLeft, Zap } from 'lucide-react';import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { Button } from '@kit/ui/button';import { PageBody } from '@kit/ui/page';import { Trans } from '@kit/ui/trans';import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@kit/ui/card';import { createI18nServerInstance } from '~/lib/i18n/i18n.server';import { withI18n } from '~/lib/i18n/with-i18n';import { TeamAccountLayoutPageHeader } from '../../../_components/team-account-layout-page-header';import { loadTeamWorkspace } from '../../../_lib/server/team-account-workspace.loader';import { loadContentDetail } from '../_lib/server/content-detail.loader';import { RepurposeForm } from './_components/repurpose-form';interface RepurposePageProps { params: Promise<{ account: string; id: string }>;}export const generateMetadata = async ({ params }: RepurposePageProps) => { const { account: accountSlug, id: contentId } = await params; try { const workspace = await loadTeamWorkspace(accountSlug); const contentData = await loadContentDetail(workspace.account.id, contentId); const i18n = await createI18nServerInstance(); const title = i18n.t('repurpose:pageTitle', { defaultValue: `Repurpose: ${contentData.content.title}`, title: contentData.content.title, }); return { title, }; } catch { const i18n = await createI18nServerInstance(); return { title: i18n.t('repurpose:pageTitle', { defaultValue: 'Repurpose Content' }), }; }};async function RepurposePage({ params }: RepurposePageProps) { const { account: accountSlug, id: contentId } = await params; // Load workspace to get account ID const workspace = await loadTeamWorkspace(accountSlug); const accountId = workspace.account.id; // Load content details const contentData = await loadContentDetail(accountId, contentId); return ( <> <TeamAccountLayoutPageHeader account={accountSlug} title={ <span className="flex items-center gap-2"> <Zap className="h-5 w-5" /> <Trans i18nKey="repurpose:pageTitle" defaults="Repurpose Content" /> </span> } description={<AppBreadcrumbs />} /> <PageBody> <div className="mx-auto max-w-4xl space-y-6"> {/* Back Navigation */} <div className="flex items-center gap-4"> <Button asChild variant="ghost" size="sm"> <Link href={`/home/${accountSlug}/content/${contentId}`}> <ArrowLeft className="mr-2 h-4 w-4" /> <Trans i18nKey="common:back" defaults="Back" /> </Link> </Button> <div className="text-sm text-muted-foreground"> <Trans i18nKey="repurpose:backToContent" defaults="Back to Content Details" /> </div> </div> {/* Content Summary */} <Card> <CardHeader> <CardTitle className="line-clamp-2"> {contentData.content.title} </CardTitle> <CardDescription> <Trans i18nKey="repurpose:contentSummary" values={{ type: contentData.content.content_type.replace('_', ' '), words: contentData.content.word_count || 0 }} defaults="{type} • {words} words" /> </CardDescription> </CardHeader> <CardContent> <div className="rounded-md bg-muted p-4"> <div className="line-clamp-3 text-sm text-muted-foreground"> {cleanTextForPreview(contentData.content.original_text)} </div> </div> </CardContent> </Card> {/* Repurpose Form */} <Card> <CardHeader> <CardTitle> <Trans i18nKey="repurpose:formTitle" defaults="AI Content Repurposing" /> </CardTitle> <CardDescription> <Trans i18nKey="repurpose:formDescription" defaults="Select the platforms you want to repurpose this content for. Our AI will adapt your content to each platform's style and requirements." /> </CardDescription> </CardHeader> <CardContent> <RepurposeForm content={contentData.content} accountSlug={accountSlug} /> </CardContent> </Card> </div> </PageBody> </> );}export default withI18n(RepurposePage);function cleanTextForPreview(text: string): string { // Quick clean for preview - remove HTML tags and markdown return text .replace(/<[^>]*>/g, '') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/#{1,6}\s+/g, '') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/```[\s\S]*?```/g, '[Code]') .replace(/\s+/g, ' ') .trim();}
Repurpose Content AI Service
The Repurpose Content AI Service is a service that takes the uploaded content and calls OpenAI API to generate social media variations:
import 'server-only';import OpenAI from 'openai';import { getLogger } from '@kit/shared/logger';// Platform-specific prompts and constraintsconst PLATFORM_CONFIGS = { twitter: { name: 'Twitter/X', maxLength: 280, characteristics: 'concise, engaging, hashtag-friendly', prompt: 'Create a Twitter post that is engaging, concise, and uses relevant hashtags. Keep it under 280 characters.', }, linkedin: { name: 'LinkedIn', maxLength: 3000, characteristics: 'professional, insightful, business-focused', prompt: 'Create a LinkedIn post that is professional, insightful, and suitable for business networking. Use a professional tone and include relevant industry insights.', }, instagram: { name: 'Instagram', maxLength: 2200, characteristics: 'visual-friendly, hashtag-heavy, engaging', prompt: 'Create an Instagram caption that is visual-friendly, engaging, and includes relevant hashtags. Focus on storytelling and emotional connection.', }, facebook: { name: 'Facebook', maxLength: 63206, characteristics: 'conversational, community-focused, story-driven', prompt: 'Create a Facebook post that encourages engagement and community interaction. Use a conversational tone and tell a compelling story.', }, tiktok: { name: 'TikTok', maxLength: 2200, characteristics: 'trendy, creative, hook-focused', prompt: 'Create a TikTok caption that is trendy, creative, and starts with a strong hook. Include trending hashtags and encourage interaction.', }, youtube: { name: 'YouTube', maxLength: 5000, characteristics: 'descriptive, SEO-friendly, call-to-action', prompt: 'Create a YouTube description that is SEO-friendly, descriptive, and includes a clear call-to-action. Optimize for discovery and engagement.', }, blog: { name: 'Blog', maxLength: 10000, characteristics: 'informative, SEO-optimized, comprehensive', prompt: 'Create a blog post excerpt or summary that is informative, SEO-optimized, and comprehensive. Focus on providing value to readers.', }, newsletter: { name: 'Newsletter', maxLength: 5000, characteristics: 'personal, value-driven, actionable', prompt: 'Create newsletter content that is personal, value-driven, and actionable. Focus on building relationships with subscribers.', },} as const;export type SupportedPlatform = keyof typeof PLATFORM_CONFIGS;export type ContentType = | 'blog_post' | 'article' | 'social_media_post' | 'newsletter' | 'video_script' | 'podcast_script';export interface RepurposeRequest { originalContent: string; contentType: ContentType; targetPlatforms: SupportedPlatform[]; customInstructions?: string; tone?: string;}export interface PlatformVariation { platform: SupportedPlatform; content: string; characterCount: number; hashtags: string[]; mentions: string[]; metadata: { model: string; tokensUsed: number; confidence: number; };}export interface RepurposeResult { variations: PlatformVariation[]; totalTokensUsed: number; processingTimeMs: number; success: boolean; error?: string;}export class ContentRepurposerService { private openai: OpenAI; constructor() { const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { throw new Error('OPENAI_API_KEY environment variable is required'); } this.openai = new OpenAI({ apiKey, }); } async repurposeContent(request: RepurposeRequest): Promise<RepurposeResult> { const startTime = Date.now(); const logger = await getLogger(); const ctx = { name: 'contentRepurposer.repurposeContent', contentType: request.contentType, platforms: request.targetPlatforms, contentLength: request.originalContent.length, }; logger.info(ctx, 'Starting content repurposing'); try { const promises: Promise<PlatformVariation>[] = []; let totalTokensUsed = 0; // Process each platform sequentially to avoid rate limits for (const platform of request.targetPlatforms) { try { const variation = this.generatePlatformVariation( request.originalContent, request.contentType, platform, request.customInstructions, request.tone, ); promises.push(variation); // Small delay between requests to be respectful to the API await new Promise((resolve) => setTimeout(resolve, 100)); } catch (error) { logger.error( { ...ctx, platform, error, }, 'Failed to generate platform variation', ); // Continue with other platforms even if one fails continue; } } const variations = await Promise.all(promises); const processingTime = Date.now() - startTime; logger.info( { ...ctx, totalTokensUsed, processingTimeMs: processingTime, successfulVariations: variations.length, }, 'Content repurposing completed', ); return { variations, totalTokensUsed, processingTimeMs: processingTime, success: true, }; } catch (error) { const processingTime = Date.now() - startTime; logger.error( { ...ctx, error, processingTimeMs: processingTime, }, 'Content repurposing failed', ); return { variations: [], totalTokensUsed: 0, processingTimeMs: processingTime, success: false, error: error instanceof Error ? error.message : 'Unknown error occurred', }; } } private async generatePlatformVariation( originalContent: string, contentType: ContentType, platform: SupportedPlatform, customInstructions?: string, tone?: string, ): Promise<PlatformVariation> { const config = PLATFORM_CONFIGS[platform]; const logger = await getLogger(); const systemPrompt = this.buildSystemPrompt( contentType, config, customInstructions, tone, ); const userPrompt = this.buildUserPrompt(originalContent, config); const response = await this.openai.chat.completions.create({ model: 'gpt-4o-mini', // Cost-effective model for content generation messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], max_tokens: Math.min(1000, Math.ceil(config.maxLength / 2)), // Reasonable max tokens temperature: 0.7, // Balance creativity with consistency presence_penalty: 0.1, // Slight penalty for repetition frequency_penalty: 0.1, // Slight penalty for common phrases }); const generatedContent = response.choices[0]?.message?.content?.trim() || ''; const tokensUsed = response.usage?.total_tokens || 0; // Extract hashtags and mentions const hashtags = this.extractHashtags(generatedContent); const mentions = this.extractMentions(generatedContent); // Calculate confidence based on length and platform constraints const confidence = this.calculateConfidence(generatedContent, config); logger.info( { platform, tokensUsed, characterCount: generatedContent.length, }, 'Generated platform variation', ); return { platform, content: generatedContent, characterCount: generatedContent.length, hashtags, mentions, metadata: { model: 'gpt-4o-mini', tokensUsed, confidence, }, }; } private buildSystemPrompt( contentType: ContentType, config: (typeof PLATFORM_CONFIGS)[SupportedPlatform], customInstructions?: string, tone?: string, ): string { const basePrompt = `You are an expert social media content creator specializing in repurposing content for ${config.name}.Your task is to transform the given ${contentType.replace('_', ' ')} into content that is perfect for ${config.name}.Platform characteristics: ${config.characteristics}Maximum length: ${config.maxLength} characters${config.prompt}`; let prompt = basePrompt; if (tone) { prompt += `\n\nTone: Use a ${tone} tone throughout the content.`; } if (customInstructions) { prompt += `\n\nAdditional instructions: ${customInstructions}`; } prompt += `\n\nIMPORTANT: - Keep the content under ${config.maxLength} characters- Maintain the core message and value of the original content- Make it native to ${config.name} platform conventions- Do not include any meta-commentary or explanations, just the content itself`; return prompt; } private buildUserPrompt( originalContent: string, config: (typeof PLATFORM_CONFIGS)[SupportedPlatform], ): string { return `Please repurpose the following content for ${config.name}:${originalContent}Remember to keep it under ${config.maxLength} characters and make it engaging for ${config.name} users.`; } private extractHashtags(content: string): string[] { const hashtagRegex = /#[\w]+/g; const matches = content.match(hashtagRegex) || []; return matches.map((tag) => tag.substring(1)); // Remove the # symbol } private extractMentions(content: string): string[] { const mentionRegex = /@[\w]+/g; const matches = content.match(mentionRegex) || []; return matches.map((mention) => mention.substring(1)); // Remove the @ symbol } private calculateConfidence( content: string, config: (typeof PLATFORM_CONFIGS)[SupportedPlatform], ): number { // Base confidence let confidence = 0.8; // Penalize if too long if (content.length > config.maxLength) { confidence -= 0.3; } // Penalize if too short (less than 10% of max length) if (content.length < config.maxLength * 0.1) { confidence -= 0.2; } // Bonus for platform-appropriate elements if (config.name === 'Twitter/X' && content.includes('#')) { confidence += 0.1; } if (config.name === 'LinkedIn' && content.includes('insights')) { confidence += 0.05; } if (config.name === 'Instagram' && content.includes('#')) { confidence += 0.1; } // Ensure confidence is between 0 and 1 return Math.max(0, Math.min(1, confidence)); }}
Claude Code made sure to add the openai
package to the apps/web/package.json
dependencies so we can use it in the service.
Issues with the generated code
There's a chicken-and-egg issue in the consume_credits
function. This function requires a Job ID, but the job ID is not available until the job is created.
So I asked Claude Code to fix it:
The repurposeContentAction is buggy: the consume credits function requires a Job ID; this is created after; currently we pass an empty string but that does not work
Claude went ahead and fixed all the issues in the codebase by adjusting the server actions to handle the repurposing job.
'use server';import { z } from 'zod';import { enhanceAction } from '@kit/next/actions';import { getLogger } from '@kit/shared/logger';import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { ContentRepurposerService, type SupportedPlatform,} from '~/lib/ai/content-repurposer.service';const RepurposeContentSchema = z.object({ contentId: z.string().uuid(), accountId: z.string().uuid(), targetPlatforms: z .array( z.enum([ 'twitter', 'linkedin', 'instagram', 'facebook', 'tiktok', 'youtube', 'blog', 'newsletter', ]), ) .min(1, 'At least one platform is required'), customInstructions: z.string().optional(), tone: z.string().optional(),});export const repurposeContentAction = enhanceAction( async function repurposeContent(data, user) { const client = getSupabaseServerClient(); const adminClient = getSupabaseServerAdminClient(); const logger = await getLogger(); const ctx = { name: 'repurposeContentAction', userId: user.id, contentId: data.contentId, accountId: data.accountId, platforms: data.targetPlatforms, }; logger.info(ctx, 'Starting content repurposing process'); try { const { data: content, error: contentError } = await client .from('original_content') .select('*') .eq('id', data.contentId) .eq('account_id', data.accountId) .single(); if (contentError || !content) { throw new Error('Content not found'); } if (content.status !== 'uploaded' && content.status !== 'completed') { throw new Error('Content is not ready for repurposing'); } // 3. Calculate credit cost (1 credit per platform) const creditsCost = data.targetPlatforms.length; // 4. Check if account has sufficient credits before creating job const { data: creditCheck } = await adminClient .from('account_credits') .select('remaining_credits') .eq('account_id', data.accountId) .lte('billing_period_start', new Date().toISOString()) .gt('billing_period_end', new Date().toISOString()) .order('billing_period_start', { ascending: false }) .limit(1) .single(); if (!creditCheck || (creditCheck.remaining_credits || 0) < creditsCost) { throw new Error( 'Insufficient credits. Please purchase more credits to continue.', ); } // 5. Create a repurposing job record const { data: job, error: jobError } = await client .from('repurposing_jobs') .insert({ account_id: data.accountId, original_content_id: data.contentId, target_platforms: data.targetPlatforms, custom_instructions: data.customInstructions, tone: data.tone, status: 'processing', credits_cost: creditsCost, created_by: user.id, }) .select() .single(); if (jobError || !job) { throw new Error('Failed to create repurposing job'); } logger.info({ ...ctx, jobId: job.id }, 'Created repurposing job'); // 6. Now consume credits with the actual job ID const consumeResult = await adminClient.rpc('consume_credits', { p_account_id: data.accountId, p_amount: creditsCost, p_job_id: job.id, p_description: `Content repurposing for ${data.targetPlatforms.length} platform(s)`, }); if (!consumeResult.data) { // If credit consumption fails, clean up the job await client .from('repurposing_jobs') .delete() .eq('id', job.id); throw new Error( 'Failed to consume credits. Please try again.', ); } // 7. Process with AI service try { const repurposerService = new ContentRepurposerService(); const result = await repurposerService.repurposeContent({ originalContent: content.original_text, contentType: content.content_type, targetPlatforms: data.targetPlatforms as SupportedPlatform[], customInstructions: data.customInstructions, tone: data.tone, }); if (!result.success) { throw new Error(result.error || 'AI processing failed'); } // 8. Save variations to database const variations = result.variations.map((variation) => ({ account_id: data.accountId, job_id: job.id, original_content_id: data.contentId, platform: variation.platform, content_text: variation.content, character_count: variation.characterCount, hashtags: variation.hashtags, mentions: variation.mentions, ai_confidence_score: variation.metadata.confidence, platform_metadata: { model: variation.metadata.model, tokens_used: variation.metadata.tokensUsed, }, })); const { error: variationsError } = await client .from('content_variations') .insert(variations); if (variationsError) { throw new Error('Failed to save content variations'); } // 9. Update job status to completed await client .from('repurposing_jobs') .update({ status: 'completed', processing_completed_at: new Date().toISOString(), ai_model_used: 'gpt-4o-mini', processing_duration_ms: result.processingTimeMs, tokens_consumed: result.totalTokensUsed, }) .eq('id', job.id); logger.info( { ...ctx, jobId: job.id, variationsCount: variations.length, tokensUsed: result.totalTokensUsed, processingTimeMs: result.processingTimeMs, }, 'Content repurposing completed successfully', ); return { success: true, data: { jobId: job.id, variationsCount: variations.length, tokensUsed: result.totalTokensUsed, processingTimeMs: result.processingTimeMs, }, }; } catch (aiError) { // Update job status to failed await client .from('repurposing_jobs') .update({ status: 'failed', error_message: aiError instanceof Error ? aiError.message : 'Unknown AI processing error', }) .eq('id', job.id); // Note: We don't refund credits for failed AI processing // as the credits were consumed for the attempt logger.error( { ...ctx, jobId: job.id, error: aiError }, 'AI processing failed after credit consumption' ); throw aiError; } } catch (error) { logger.error({ ...ctx, error }, 'Content repurposing failed'); return { success: false, error: error instanceof Error ? error.message : 'Repurposing failed', }; } }, { auth: true, schema: RepurposeContentSchema, },);
When you kick-off a generation job, here's what the variations page looks like (without any follow-up adjustments or fixes):

As you can see, there are various little bugs and issues such as:
- crammed content
- missing translations
And you know what? That's wonderful - because it works! These are such little issues that we can fix them later in a matter of seconds or follow ups. But we're too focused on the big picture and the value proposition of the SaaS - which Claude Code was able to build with minimal guidance and effort. Incredible.
Billing
The last thing we want Claude Code to add for us is handling credit refilling when a user pays an invoice. This is so that subscribed users can keep using the SaaS without having to worry about running out of credits.
I went ahead and added the following prompt to Claude Code:
We now want to refill the account credits when an invoice gets paid which allows users to keep using the saas if they are paying users. We use Stripe.
Understandably, Claude Code was not able to implement this feature in a satisfactory way - for example, it didn't check the Stripe signature when receiving the webhook, which a security risk. This is one of those times where developer guidance is required.
I reverted the changes, and gave Claude Code the Makerkit's documentation for handling billing webhooks according to the SaaS Starter Kit's best practices:
We now want to refill the account credits when an invoice gets paid which allows users to keep using the saas if they are paying users. We use Stripe. Use the Makerkit docs for reference on handling webhooks: https://makerkit.dev/docs/next-supabase-turbo/billing/billing-webhooks
Claude Code struggled a bit with Stripe's pricing, which I suspect it's due to very recent changes to the Stripe API. After guiding it manually, it was able to implement the feature with minimal issues.
First, it added the following Postgres functions to the existing schema 18-content-repurposer.sql
:
-- Function to add credits to an account (for billing refills)CREATE OR REPLACE FUNCTION public.add_credits( p_account_id UUID, p_amount INTEGER, p_plan_name TEXT, p_order_id TEXT DEFAULT NULL, p_description TEXT DEFAULT 'Credit refill')RETURNS UUIDLANGUAGE plpgsqlSECURITY DEFINERSET search_path TO ''AS $$DECLARE v_credits_id UUID; v_current_balance INTEGER; v_billing_start TIMESTAMPTZ; v_billing_end TIMESTAMPTZ;BEGIN -- Security check: only allow service_role to execute this function IF auth.role() != 'service_role' THEN RAISE EXCEPTION 'Only service role can add credits'; END IF; -- Set billing period (monthly for now) v_billing_start := date_trunc('month', NOW()); v_billing_end := v_billing_start + interval '1 month'; -- Try to get existing credits for current billing period SELECT id, remaining_credits INTO v_credits_id, v_current_balance FROM public.account_credits WHERE account_id = p_account_id AND billing_period_start = v_billing_start LIMIT 1; IF v_credits_id IS NULL THEN -- Create new credit record for this billing period INSERT INTO public.account_credits ( account_id, total_credits, used_credits, billing_period_start, billing_period_end, plan_name, auto_refill ) VALUES ( p_account_id, p_amount, 0, v_billing_start, v_billing_end, p_plan_name, TRUE ) RETURNING id, remaining_credits INTO v_credits_id, v_current_balance; ELSE -- Update existing credit record UPDATE public.account_credits SET total_credits = total_credits + p_amount, plan_name = COALESCE(p_plan_name, plan_name) WHERE id = v_credits_id RETURNING remaining_credits INTO v_current_balance; END IF; -- Record transaction INSERT INTO public.credit_transactions ( account_id, account_credits_id, transaction_type, amount, description, order_id, balance_before, balance_after ) VALUES ( p_account_id, v_credits_id, 'purchase', p_amount, p_description, p_order_id, v_current_balance - p_amount, v_current_balance ); RETURN v_credits_id;END;$$;-- Function to get credit package amount based on price IDCREATE OR REPLACE FUNCTION public.get_credit_package_for_price( p_price_id TEXT)RETURNS TABLE( credits INTEGER, plan_name TEXT)LANGUAGE plpgsqlSECURITY DEFINERSET search_path TO ''AS $$BEGIN -- Map Stripe price IDs to credit packages -- This should match your billing configuration CASE p_price_id WHEN 'price_1NNwYHI1i3VnbZTqI2UzaHIe' THEN -- Starter Monthly RETURN QUERY SELECT 100 as credits, 'Starter Monthly' as plan_name; WHEN 'starter-yearly' THEN -- Starter Yearly RETURN QUERY SELECT 1200 as credits, 'Starter Yearly' as plan_name; WHEN 'price_1PGOAVI1i3VnbZTqc69xaypm' THEN -- Pro Monthly RETURN QUERY SELECT 500 as credits, 'Pro Monthly' as plan_name; WHEN 'price_pro_yearly' THEN -- Pro Yearly RETURN QUERY SELECT 6000 as credits, 'Pro Yearly' as plan_name; WHEN 'price_enterprise-monthly' THEN -- Enterprise Monthly RETURN QUERY SELECT 2000 as credits, 'Enterprise Monthly' as plan_name; WHEN 'price_enterprise_yearly' THEN -- Enterprise Yearly RETURN QUERY SELECT 24000 as credits, 'Enterprise Yearly' as plan_name; ELSE -- Default fallback for unknown price IDs RETURN QUERY SELECT 0 as credits, 'Unknown Plan' as plan_name; END CASE;END;$$;GRANT EXECUTE ON FUNCTION public.add_credits TO service_role;GRANT EXECUTE ON FUNCTION public.get_credit_package_for_price TO service_role;
And then, it added the following code to the apps/web/app/api/billing/webhook/route.ts
file:
import Stripe from 'stripe';import { getPlanTypesMap } from '@kit/billing';import { getBillingEventHandlerService } from '@kit/billing-gateway';import { enhanceRouteHandler } from '@kit/next/routes';import { getLogger } from '@kit/shared/logger';import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';import billingConfig from '~/config/billing.config';/** * @description Handle credit refills when invoices are paid * @param accountId - The account to add credits to * @param priceId - The Stripe price ID to determine credit amount * @param orderId - The order/invoice ID for audit trail */async function handleCreditRefill( accountId: string, priceId: string, orderId: string,) { const logger = await getLogger(); const client = getSupabaseServerAdminClient(); const ctx = { name: 'billing.webhook.credit-refill', accountId, priceId, orderId, }; logger.info(ctx, 'Processing credit refill for invoice payment'); try { // Get credit package for this price ID const { data: creditPackage, error: packageError } = await client .rpc('get_credit_package_for_price', { p_price_id: priceId, }) .single(); if (packageError) { throw new Error(`Failed to get credit package: ${packageError.message}`); } if (!creditPackage || creditPackage.credits === 0) { logger.warn(ctx, `No credit package found for price ID: ${priceId}`); return; } // Add credits to account const { data: creditsId, error: addError } = await client.rpc( 'add_credits', { p_account_id: accountId, p_amount: creditPackage.credits, p_plan_name: creditPackage.plan_name, p_order_id: orderId, p_description: `Credit refill: ${creditPackage.plan_name}`, }, ); if (addError) { throw new Error(`Failed to add credits: ${addError.message}`); } logger.info( { ...ctx, creditsAdded: creditPackage.credits, planName: creditPackage.plan_name, creditsId, }, 'Successfully added credits to account', ); } catch (error) { logger.error({ ...ctx, error }, 'Failed to process credit refill'); throw error; }}/** * @description Handle the webhooks from Stripe related to checkouts */export const POST = enhanceRouteHandler( async ({ request }) => { const provider = billingConfig.provider; const logger = await getLogger(); const ctx = { name: 'billing.webhook', provider, }; logger.info(ctx, `Received billing webhook. Processing...`); const supabaseClientProvider = () => getSupabaseServerAdminClient(); const service = await getBillingEventHandlerService( supabaseClientProvider, provider, getPlanTypesMap(billingConfig), ); try { await service.handleWebhookEvent(request, { // Handle invoice payment events for credit refills onEvent: async (event: Stripe.Event) => { if (event.type === 'invoice.payment_succeeded') { const invoice = event.data.object as Stripe.Invoice; const accountId = invoice.metadata?.account_id; const lineItem = invoice.lines?.data?.[0]!; const priceId = lineItem.pricing?.price_details?.price; if (accountId && priceId && typeof priceId === 'string') { await handleCreditRefill(accountId, priceId, invoice.id || ''); } else { logger.warn( { ...ctx, invoiceId: invoice.id }, 'Invoice payment succeeded but missing account_id or price_id', ); } } }, }); logger.info(ctx, `Successfully processed billing webhook`); return new Response('OK', { status: 200 }); } catch (error) { logger.error({ ...ctx, error }, `Failed to process billing webhook`); return new Response('Failed to process billing webhook', { status: 500, }); } }, { auth: false, },);
I would have personally added some of the code to the application-level or used a join table to find the plan information, so code-wide I do think Claude Code could have gone a bit further. However, in the spirit of true vibe-coding, I let it as is.
With this new addition, we now have a working SaaS that allows users to create social media variations of their content, and also allows them to keep using the SaaS if they are paying users!
Conclusion
That's it! We have a working SaaS with a value proposition that allows users to create social media variations of their content.
All of this was built using the Makerkit base kit and Claude Code, in about 10 prompts, a little bit of guidance along the way. All of this was done within the Claude Code Pro subscription ($20/month), which is a great deal for the value it provides.