Building an Interactive Proposal System with Sanity CMS

Sending proposals used to mean hours in Google Docs, exporting to PDF, emailing back and forth, and praying the client wouldn't ghost you. Then came the acceptance process—screenshot their 'I agree' email, manually create the project in your system, copy-paste details into Notion or Linear. It was tedious, error-prone, and unprofessional.
We needed something better. A proposal system that felt modern, allowed clients to accept proposals with a digital signature, collected payments when needed, sent automatic notifications, and tracked everything in one place. Most importantly, we wanted it fully integrated with our existing Sanity CMS setup—no third-party services, no monthly subscriptions, complete control.
Here's how we built it, what we learned, and the exact architecture you can use to build your own.
The Requirements
Before writing any code, we documented exactly what our proposal system needed to do:
- Multiple package options (MVP, Full Build, Custom) with side-by-side comparison
- Digital signature acceptance with name and email capture
- Optional Stripe payment integration for collecting deposits
- Video embeds that auto-play in a modal (perfect for Loom walkthrough videos)
- Email and Discord notifications when proposals are accepted
- Kanban board for managing proposal pipeline (draft, sent, accepted, rejected)
- Print/PDF export for offline sharing
- Mobile-responsive design (many clients review proposals on their phones)
[IMAGE: Screenshot of the proposal comparison view showing two packages side-by-side with pricing, features, and select buttons]
The Stack
We built this on our existing Next.js and Sanity foundation:
- Next.js 14 with Pages Router for the proposal pages and API routes
- Sanity v3 for content modeling and storage
- Stripe Payment Intents API for optional deposit collection
- Resend for transactional emails
- Discord webhooks for team notifications
- TypeScript throughout for type safety
The beauty of this stack is that it's all integrated. The Sanity Studio lives at /studio in our Next.js app, proposals are stored as Sanity documents, and we use Next.js API routes to handle acceptance webhooks. One codebase, one deployment, zero external services to manage (except Stripe for payments, which you need anyway).
Content Modeling: The Sanity Schemas
The foundation of any Sanity project is the schema. We needed four main schema types to model our proposal system:
1. The Proposal Document
This is the core document type that represents a complete proposal. Each proposal includes:
- Basic info: title, slug, client name, description
- Pricing: hourly rate (used to calculate package costs)
- Status: draft, sent, accepted, rejected, or expired
- Options array: multiple package choices (we'll cover this next)
- CTA customization: heading and description for the acceptance section
- Video embed URL: optional Loom video that auto-plays on page load
- Payment requirement: toggle for whether to collect payment on acceptance
- Contact details: email addresses for questions and notifications
The schema definition is straightforward with field-level validation. We require title, slug, client name, hourly rate, and at least one option. The status field defaults to 'draft' and uses a dropdown with predefined values.
[IMAGE: Screenshot of the Sanity Studio editing a proposal document, showing the form fields and validation]
2. The Proposal Option Object
This is a reusable object type (not a document) that defines a package option within a proposal. Think 'MVP Package' or 'Full Build Package.' Each option includes:
- Name, price, hours, timeline, and launch date
- Optional badge (like 'Most Popular' or 'Best Value')
- Theme color for the comparison view (blue, green, purple, orange)
- Features list: what's included in this package
- Not included list: what's excluded (helps differentiate packages)
- Scope of work: detailed breakdown with sections and subsections
- Time breakdown: task-by-task hour estimates
- Payment milestones: when payments are due and percentages
This structure gives us incredible flexibility. A proposal can have one option or ten. Each option can be as simple or detailed as needed. For quick proposals, we might just fill in name, price, and features. For complex enterprise deals, we include full scope of work, detailed time breakdowns, and payment schedules.
The time breakdown is particularly useful. It's an array of objects with 'task' and 'hours' fields. The component automatically calculates the cost using the proposal's hourly rate and displays a clean table. This transparency builds trust—clients see exactly where the hours go.
3. The Proposal Acceptance Document
When a client accepts a proposal, we create a proposal acceptance document that records:
- Reference to the accepted proposal
- Signer information (name, email)
- Which package option was selected
- Acceptance timestamp
- IP address and user agent (for legal records)
- Payment status (pending, completed, or failed)
- Stripe payment intent ID (if payment was collected)
- Project status tracking (new, contacted, contract sent, in progress, completed, cancelled)
This document becomes the source of truth for the engagement. We can track the entire project lifecycle from initial acceptance through completion. The status field integrates with our project management workflow—when we send the contract, we update it to 'contract_sent'; when development starts, it becomes 'in_progress.'
4. The Proposal Settings Singleton
Global configuration for the proposal system, stored as a singleton document (only one can exist):
- Discord webhook URL for team notifications
- Email notification settings (Resend configuration)
- Test mode toggle for safely testing notifications
The singleton pattern is perfect for this. You can't accidentally create duplicate settings documents, and the Studio UI makes it obvious where to configure global options.
[IMAGE: Screenshot of the proposal settings singleton in Sanity Studio with Discord webhook and email configuration fields]
The Frontend: Interactive Proposal Pages
The proposal viewing experience needed to feel modern and interactive while remaining simple. We built a client component called ProposalRenderer that handles all the UI logic.
Key Features
Single View with Package Selection
When a proposal has multiple options, we show a package selector at the top—clean toggle buttons that update the entire page content when clicked. The selected package's details (price, timeline, hours, launch date) display in stat cards with gradient backgrounds. Below that, we render the features list with checkmark icons, the full scope of work in an expandable accordion, time breakdown in a table, and payment terms with milestone visualizations.
We use React's useState to track which option is selected and useMemo to generate consistent random hover colors for buttons (seeded so they don't change on re-renders). The result is a smooth, app-like experience that updates instantly as users explore different packages.
[IMAGE: Screenshot showing the single view with package selector toggle buttons and stat cards displaying price, timeline, hours, and launch date]
Comparison View
Hit the 'Compare Packages' button and the view transforms into a side-by-side layout showing all options at once. Each package gets its own card with color-coded borders and backgrounds (controlled by the color field in the schema), a prominent price display, and a features checklist. This view makes it incredibly easy for clients to see exactly what they get (or don't get) with each tier.
The 'Choose This Package' buttons in comparison view update the selection and automatically switch back to single view, creating a natural flow: compare options, pick one, review details, accept.
[IMAGE: Screenshot of comparison view with two or three packages displayed side-by-side with color-coded cards and feature lists]
Auto-Playing Video Modal
Here's a game-changer: if you add a video URL to the proposal (typically a Loom walkthrough explaining the proposal), it automatically opens in a modal 500ms after the page loads. The client sees your face, hears your voice explaining the options, and it adds a personal touch that static text can't match.
We built the ProposalVideoModal component to support Loom, YouTube, and Vimeo. It converts share URLs to embed URLs, handles postMessage events for auto-close when the video ends, and stores a flag in state to prevent the modal from re-opening on every render. There's also a floating replay button in the bottom-right corner if they want to watch again.
The conversion lift from adding video walkthroughs has been significant. Clients understand the proposal better, have fewer questions, and accept faster.
[IMAGE: Screenshot of the video modal overlay showing an embedded Loom video with a close button]
Digital Signature Flow
At the bottom of every proposal is the call-to-action section with dark background for emphasis. If the proposal hasn't been signed yet (tracked in localStorage), they see a prominent 'Accept Proposal' button. Clicking it reveals a form asking for name and email—simple, but legally sufficient as a digital signature.
On submit, we POST to /api/proposal/accept with the proposal slug, selected option index, signer info, and timestamp. The API route handles everything: creates the acceptance document in Sanity, updates the proposal status to 'accepted', sends notifications, and either redirects to a confirmation page or to the payment page (if payment is required).
If the client has already signed (detected via localStorage check on load), we show a green confirmation message with their name and the acceptance date instead of the form. This prevents duplicate acceptances and provides immediate feedback if they revisit the page.
Print & PDF Export
Some clients want a PDF for their records or to share internally. We added a print button in the header that triggers window.print(), and we wrote custom print CSS to hide interactive elements (buttons, video, signature form) and optimize the layout for paper. The result is a clean, professional PDF that includes all the proposal details but removes the digital-only features.
The Backend: Acceptance API and Notifications
The acceptance flow happens in a Next.js API route at /api/proposal/accept. This endpoint does a lot of heavy lifting, and we designed it to be robust and fail gracefully.
The Acceptance Flow
- Validate the request: ensure we have all required fields (proposalSlug, optionIndex, name, email)
- Fetch the proposal from Sanity using the slug
- Verify the selected option index is valid
- Check if this email has already accepted this proposal (prevents duplicates)
- Create a proposal acceptance document in Sanity with all the details
- Update the proposal's status to 'accepted'
- Send Discord notification to the team with rich embed
- Send email notification via Resend
- If payment is required, redirect to payment page; otherwise, redirect to confirmation
Each step includes error handling. If Sanity is down, we log the error and return a 500. If Discord webhook fails, we log it but don't fail the request—notifications are nice-to-have, not critical. The acceptance still succeeds and gets recorded.
Notification System
When a proposal is accepted, we POST to the Discord webhook URL with a rich embed containing the client name, package selected, price, and a direct link to the acceptance record in Sanity Studio. The team sees the notification instantly in our dedicated channel, and we can jump directly into the Studio to review details or update the project status.
We also send an email via Resend to the notification email address (configured in proposal settings). This email includes all the acceptance details, signer info, selected package, and next steps. It serves as both a notification and a record for our inbox.
Both notification channels are configured through the Sanity Studio interface, so non-technical team members can update webhook URLs or email addresses without touching code.
[IMAGE: Screenshot of Discord notification showing proposal acceptance with rich embed including client name, package, and price]
Stripe Payment Integration
Some proposals require payment on acceptance—typically collecting the first milestone payment as a deposit. We built this as an optional feature controlled by the requiresPayment boolean field on proposals.
When enabled, the acceptance flow redirects to a payment page instead of going directly to confirmation. This page displays the proposal details, the selected package, and calculates the first payment amount based on the first milestone percentage. It shows a Stripe payment form (using Stripe Elements) that securely collects payment information.
Creating Payment Intents
We use the Payment Intents API (not the older Charges API) because it supports modern payment flows, strong customer authentication (SCA), and provides detailed webhook events. When the payment page loads, we call /api/proposal/create-payment-intent, which:
- Fetches the proposal and acceptance record
- Calculates the payment amount from the first milestone percentage
- Creates a Stripe Payment Intent with metadata (proposal ID, acceptance ID, client email)
- Returns the client secret to the frontend
The payment form component uses this client secret to initialize the Stripe Elements, which handles all the complexity of collecting card information securely. When the user submits, Stripe processes the payment and we wait for webhook confirmation.
Handling Payment Success
After payment succeeds, Stripe sends a webhook event to our confirm-payment endpoint. We verify the webhook signature (critical for security), extract the Payment Intent metadata, and update the acceptance record in Sanity to mark payment as completed and store the Stripe payment intent ID for reference.
This webhook-based approach is more reliable than client-side confirmation because it works even if the user closes the browser tab after payment. Stripe will retry failed webhooks, ensuring we never miss a successful payment.
The Kanban Board: Managing Your Pipeline
Having all proposals in Sanity is great, but browsing them as a document list isn't ideal for pipeline management. We needed a visual way to see where each proposal stands—draft, sent, accepted, rejected, or expired.
Enter the custom Sanity Studio plugin: a drag-and-drop Kanban board that lives right in the Studio interface. It queries all proposal documents, groups them by status, and displays them as cards in columns. Drag a card from 'Draft' to 'Sent' and it automatically updates the document's status field. It's satisfying, intuitive, and keeps everyone aligned on what proposals are where.
[IMAGE: Screenshot of the Kanban board plugin in Sanity Studio showing columns for draft, sent, accepted, rejected, and expired with proposal cards]
Technical Implementation
We built the Kanban board as a Sanity Studio plugin using React and a drag-and-drop library. The board component fetches proposals using Sanity's useDocuments hook with a GROQ query filtered by document type. Each column is a droppable zone, and each card is draggable. On drop, we use Sanity's client to mutate the document and update the status field.
Clicking a card opens a detail drawer showing the full proposal information—client name, selected packages, total value, acceptance status. There's a link to open the full document in the Studio editor and a link to view the live proposal page. This gives us a quick overview without leaving the Kanban view.
Because Sanity supports real-time listeners, changes made by one team member appear instantly for everyone else. If a proposal moves to 'Accepted' via the acceptance API, the Kanban board updates automatically. No manual refresh needed.
[IMAGE: Screenshot of the proposal detail drawer showing client info, package details, and action buttons]
TypeScript Integration: Type Safety Throughout
One of the best parts of using Sanity with TypeScript is the automatic type generation. We use Sanity's schema extraction tool to generate TypeScript types directly from our schema definitions. This means every proposal field, every option property, every acceptance record—all fully typed.
When we fetch a proposal document in our API route or component, TypeScript knows exactly what fields exist, what their types are, and whether they're required or optional. If we try to access a field that doesn't exist, the compiler catches it immediately. If we rename a field in the schema and forget to update code somewhere, the build fails with clear error messages pointing to the problem.
Our workflow is simple: update the schema, run the extraction script (happens automatically on dev server start), and all the types update. No manual maintenance, no drift between schema and code.
Real-World Usage: How We Actually Use This
Let me walk you through our actual workflow from inquiry to accepted proposal:
- Client reaches out via email or contact form. We have an initial call to understand their needs.
- We open Sanity Studio at /studio, create a new proposal document, and fill in the client name, title, and hourly rate.
- We add 2-3 proposal options (MVP, Standard, Premium) with different scope levels. The features lists help us think through exactly what each tier includes.
- We record a 3-5 minute Loom video walking through the proposal, explaining the options, and answering common questions. We paste the Loom share URL into the videoEmbed field.
- We set the status to 'Sent', publish, and send the client an email with the proposal URL (yourdomain.com/proposals/client-name).
- Client opens the link, watches the video, explores the packages, asks a few questions over email, then accepts their chosen package by filling in their name and email.
- We get a Discord notification and email notification instantly. The Kanban board shows the proposal moved to 'Accepted.'
- We open the acceptance record in Sanity, see all the details, and update the project status to 'contacted' after sending the contract.
The entire process—from initial call to accepted proposal—often happens within 24 hours. Compare that to the old workflow of drafting in Google Docs, exporting to PDF, emailing, waiting for confirmation, manually logging everything. This is 10x faster and feels professional.
Lessons Learned and Gotchas
Building this system taught us a few important lessons:
Always Check for Existing Acceptances
Early on, we forgot to check if an email had already accepted a proposal. A client accidentally accepted the same proposal twice because they refreshed the page at the wrong time. Now we query for existing acceptances before creating a new one and return the existing record if found.
Notification Failures Should Not Fail the Request
Discord was briefly down one day, and our acceptance endpoint started returning 500 errors because the webhook call failed. We refactored to wrap notification calls in try-catch blocks and log errors without failing the request. The acceptance still succeeds and gets recorded, we just don't get the notification.
Video Auto-Play is Powerful but Can Be Annoying
We made auto-play opt-in per proposal rather than globally enabled. Some proposals don't need video, and forcing a modal on every page load when there's no video feels janky. Check if videoEmbed exists before attempting to show the modal.
Use Stripe Test Mode Religiously
Before enabling payment on a real proposal, test the entire flow in Stripe test mode. Use test card numbers, confirm the webhook fires correctly, verify the acceptance record updates. Payment flows have lots of edge cases (declined cards, network timeouts, webhook retries), and testing catches most of them.
Mobile Experience Matters More Than You Think
Over 40% of our proposal views happen on mobile devices. Make sure the package selector is usable with touch, the comparison view scrolls horizontally on small screens, and the signature form fields are appropriately sized for mobile keyboards. We use responsive design throughout and test on real devices.
Performance Considerations
Proposal pages need to load fast. First impressions matter, and a slow page signals lack of attention to detail. Here's how we optimized:
- Static generation: We use Next.js's generateStaticParams to pre-render all proposal pages at build time. The pages are served as static HTML, which loads almost instantly.
- Incremental Static Regeneration: When a proposal is updated in Sanity, we can revalidate just that page without rebuilding the entire site.
- Optimized images: Any images in proposals go through Next.js Image optimization, which serves WebP/AVIF formats and appropriate sizes.
- Minimal client JavaScript: Most of the page is static HTML. The client component handles interactivity (package selection, video modal, signature form), but we lazy-load non-critical parts.
- Video embeds load on-demand: The video modal doesn't load the embed until the modal opens, saving bandwidth if the user never watches.
The result? Proposal pages load in under 1 second on decent connections, Lighthouse scores are consistently in the 90s, and we've never had a client complain about performance.
Future Enhancements We're Considering
The system works great as-is, but we have a few ideas for future improvements:
- Proposal analytics: Track which packages get viewed most, where clients drop off, how long they spend on each section. This data would help us optimize proposals over time.
- Automated follow-ups: If a proposal is sent but not accepted after 3 days, automatically send a gentle reminder email.
- E-signature integration: While our current digital signature is legally sufficient for most cases, integrating with DocuSign or similar would add an extra layer of formality for enterprise clients.
- Contract generation: Auto-generate the full service contract from the accepted proposal data, reducing manual work.
- Proposal templates: Create reusable templates for common project types (e-commerce site, SaaS MVP, mobile app) to speed up proposal creation.
- Interactive pricing calculator: Let clients adjust scope sliders and see the price update in real-time. This could be powerful for exploration but requires careful UX design.
The Results: Faster Sales, Better Client Experience
Since launching this proposal system six months ago, we've seen measurable improvements:
- Proposal-to-acceptance time decreased by 60% (from average 5 days to 2 days)
- Acceptance rate increased from 45% to 62%
- Time spent creating proposals reduced by 70% (templates and copy-paste from previous proposals)
- Client questions before acceptance decreased significantly (video walkthrough answers most questions upfront)
- Zero missed acceptances or lost client information (everything is automatically logged)
The client feedback has been overwhelmingly positive. Multiple clients have commented on how professional and easy to use the proposals are compared to static PDFs. The video walkthrough in particular gets praise—clients appreciate the personal touch and find it easier to understand the options when they can hear us explain it.
From the team perspective, it's a huge relief. Creating proposals used to be a chore that we'd procrastinate on. Now it's quick enough that we can send proposals the same day as the call, while the conversation is fresh in everyone's mind. The Kanban board gives us visibility into the pipeline at a glance, and the automatic notifications mean we never miss an acceptance.
Should You Build This for Your Business?
If you're sending more than 5-10 proposals per year and you already have (or are planning) a Next.js and Sanity setup, absolutely yes. The initial build took about 2-3 weeks of development time, but we've saved far more than that in efficiency gains and won deals we might have lost with a slower, clunkier process.
If you're not already using Sanity or Next.js, evaluate the total scope. You'll need to set up the CMS, configure hosting, handle authentication for the Studio, and integrate payment processing if you want that feature. It's doable, but it's not a weekend project.
For agencies, consultancies, and service businesses that send custom proposals regularly, this system is a competitive advantage. While your competitors are emailing static PDFs and manually tracking who accepted what, you're delivering interactive proposals with video, collecting digital signatures, and automatically syncing everything to your CRM. It's a better experience for clients and a better workflow for your team.
Wrapping Up
Building a custom proposal system with Sanity CMS transformed how we sell our services. What used to be a manual, error-prone process is now streamlined, professional, and fully automated. Clients get a better experience, we close deals faster, and we have complete visibility into our sales pipeline.
The combination of Sanity's flexible content modeling, Next.js's performance and developer experience, and Stripe's robust payment infrastructure gave us everything we needed to build exactly the system we wanted. No compromises, no monthly subscriptions to third-party tools, complete control.
If you're building something similar or have questions about our implementation, feel free to reach out. I'm happy to share code snippets, discuss architecture decisions, or help you avoid the gotchas we encountered along the way.
Now go build something great—and send better proposals than your competitors.

