Integrate Bolt.new with Zoho CRM using the Zoho CRM REST API via Next.js API routes. Register an app in Zoho Developer Console, implement OAuth 2.0 (requires your deployed URL for the redirect), then fetch contacts, leads, and deals. Build a custom CRM dashboard with pipeline management. The free tier supports up to 3 users. API rate limits vary by plan. All outbound calls work in Bolt's WebContainer preview.
Build a Custom Zoho CRM Dashboard in Bolt.new
Zoho CRM is a widely adopted alternative to Salesforce and HubSpot, particularly popular with small and mid-market businesses. Its REST API (currently v6) follows a straightforward module-based pattern: every type of data — Contacts, Leads, Deals, Accounts, Activities — lives in a module, and you interact with any module using the same CRUD endpoints. This consistency makes the API predictable once you understand the pattern, and it translates well to a Bolt-generated dashboard where you might display several modules side by side.
Zoho CRM's free tier supports up to three users, making it accessible for early-stage startups that want CRM capabilities without subscription costs. As a team grows, paid plans (Standard at $14/user/month through Ultimate) add more records, storage, automation, and API call limits. The API itself is available on all paid plans and with limited calls on the free tier — sufficient for building and testing an integration before a team commits to a plan.
The main integration complexity is OAuth 2.0. Unlike simpler APIs that use a static API key, Zoho CRM uses OAuth 2.0 authorization code flow. This requires registering your application in Zoho Developer Console, getting user authorization through a browser redirect, exchanging the authorization code for access and refresh tokens, and using the refresh token to maintain access when the short-lived access token expires. Crucially, the redirect URI that Zoho sends the user back to after authorization must be a publicly accessible URL — which means the OAuth setup step requires deployment. Once you have a refresh token stored, all subsequent API calls work in the WebContainer preview.
Integration method
Bolt generates Next.js API routes that handle Zoho CRM OAuth 2.0 token exchange and subsequent API calls, keeping client credentials server-side. Outbound calls to Zoho's REST API work in Bolt's WebContainer for development and testing. The OAuth redirect URI step requires a deployed URL since Zoho redirects the browser after authorization — use Netlify or Bolt Cloud for the OAuth flow. Once tokens are obtained and stored, the CRM data endpoints work in the preview.
Prerequisites
- A Bolt.new account with a Next.js project
- A Zoho CRM account (free tier works for development)
- A Zoho Developer Console account at api-console.zoho.com
- A deployed URL (Netlify or Bolt Cloud) for the OAuth redirect URI — required to complete the initial OAuth authorization
- Node.js knowledge for understanding the OAuth token exchange flow
Step-by-step guide
Register Your App in Zoho Developer Console
Register Your App in Zoho Developer Console
Zoho CRM requires OAuth 2.0 for API access, which means you need to register your application before making any API calls. Go to api-console.zoho.com and sign in with your Zoho account. Click 'Add Client' and select 'Server-based Applications' as the client type — this is the correct choice for a Next.js backend application that will exchange authorization codes for tokens on the server. Fill in the client details: Client Name (your application name), Homepage URL (your deployed app URL or bolt.new while developing), and Authorized Redirect URIs. The redirect URI is where Zoho will send users after they authorize your app. This must be a publicly accessible HTTPS URL — for production use your Netlify URL like `https://your-app.netlify.app/api/zoho/callback`. You cannot use localhost or Bolt's WebContainer preview URL because Zoho requires an accessible server to receive the redirect. For development convenience, you can also add your deployed URL during initial setup — Bolt Cloud or Netlify makes deployment fast enough that you can deploy, register the redirect URI, complete OAuth, and then continue development in the preview using the stored refresh token. After creating the client, Zoho generates a Client ID and Client Secret. Copy both values — you will add them to your .env file. Also note the data center your Zoho account is on: accounts.zoho.com (US), accounts.zoho.eu (EU), accounts.zoho.in (India), etc. The data center determines which base URL to use for API calls.
Add Zoho CRM OAuth configuration to the .env file: ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REDIRECT_URI (your deployed URL + /api/zoho/callback), and ZOHO_ACCOUNTS_URL (https://accounts.zoho.com for US data center). Create a lib/zoho.ts file with a getZohoAccessToken() function that reads ZOHO_REFRESH_TOKEN from process.env and exchanges it for a fresh access token using the Zoho token endpoint. Cache the access token in memory for up to 55 minutes (tokens expire at 60 minutes).
Paste this in Bolt.new chat
1// lib/zoho.ts2const ZOHO_ACCOUNTS_URL = process.env.ZOHO_ACCOUNTS_URL || 'https://accounts.zoho.com';3const ZOHO_API_URL = process.env.ZOHO_API_URL || 'https://www.zohoapis.com/crm/v6';45let cachedToken: { token: string; expiresAt: number } | null = null;67export async function getZohoAccessToken(): Promise<string> {8 // Return cached token if still valid (with 5 min buffer)9 if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {10 return cachedToken.token;11 }1213 const refreshToken = process.env.ZOHO_REFRESH_TOKEN;14 const clientId = process.env.ZOHO_CLIENT_ID;15 const clientSecret = process.env.ZOHO_CLIENT_SECRET;1617 if (!refreshToken || !clientId || !clientSecret) {18 throw new Error('Zoho CRM credentials are not configured. Set ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, and ZOHO_REFRESH_TOKEN.');19 }2021 const response = await fetch(`${ZOHO_ACCOUNTS_URL}/oauth/v2/token`, {22 method: 'POST',23 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },24 body: new URLSearchParams({25 grant_type: 'refresh_token',26 client_id: clientId,27 client_secret: clientSecret,28 refresh_token: refreshToken,29 }),30 });3132 const data = await response.json() as { access_token?: string; error?: string };3334 if (!data.access_token) {35 throw new Error(`Zoho token refresh failed: ${data.error || 'unknown error'}`);36 }3738 cachedToken = {39 token: data.access_token,40 expiresAt: Date.now() + 60 * 60 * 1000, // 60 minutes41 };4243 return data.access_token;44}4546export async function zohoFetch<T = unknown>(47 endpoint: string,48 options: RequestInit = {}49): Promise<T> {50 const accessToken = await getZohoAccessToken();51 const response = await fetch(`${ZOHO_API_URL}/${endpoint}`, {52 ...options,53 headers: {54 Authorization: `Zoho-oauthtoken ${accessToken}`,55 'Content-Type': 'application/json',56 ...options.headers,57 },58 });5960 if (!response.ok) {61 const error = await response.json().catch(() => ({})) as { message?: string };62 throw new Error(error.message || `Zoho API error: ${response.status}`);63 }6465 return response.json() as Promise<T>;66}Pro tip: Note your Zoho data center carefully. EU accounts use accounts.zoho.eu and www.zohoapis.eu. India accounts use accounts.zoho.in and www.zohoapis.in. Using the wrong data center URL results in authentication errors even with correct credentials.
Expected result: The Zoho developer app is registered and you have Client ID and Client Secret. The lib/zoho.ts token refresh helper is ready to exchange the refresh token for access tokens.
Complete the OAuth Flow to Get a Refresh Token
Complete the OAuth Flow to Get a Refresh Token
OAuth 2.0 authorization code flow requires a browser redirect step that cannot be completed inside Bolt's WebContainer. You need a deployed URL for the redirect URI. The fastest path is deploying to Netlify or Bolt Cloud first, completing the OAuth flow once to get a long-lived refresh token, then storing that token as an environment variable for all future use. Create two API routes: an authorization initiation route that redirects to Zoho's consent screen, and a callback route that exchanges the authorization code for tokens. The authorization URL format is: `https://accounts.zoho.com/oauth/v2/auth?scope=ZohoCRM.modules.ALL&client_id={CLIENT_ID}&response_type=code&access_type=offline&redirect_uri={REDIRECT_URI}`. The `access_type=offline` parameter is critical — without it, Zoho only returns an access token (60-minute expiry) without a refresh token. The callback route receives the authorization code as a query parameter and makes a POST request to exchange it. The exchange response includes both an `access_token` and a `refresh_token`. The refresh token is long-lived (it does not expire as long as you use it periodically). Log the refresh token to the server console or return it in the API response so you can copy it. Once you have the refresh token, add it to your .env file as `ZOHO_REFRESH_TOKEN` and you will not need to go through the OAuth flow again (unless you revoke access or the token becomes invalid after extended non-use). The `getZohoAccessToken()` function handles all subsequent token refreshes automatically. For the scope, `ZohoCRM.modules.ALL` grants read and write access to all CRM modules. Use more specific scopes if you only need read access: `ZohoCRM.modules.contacts.READ,ZohoCRM.modules.deals.READ`.
Create two Next.js API routes for Zoho OAuth. First: app/api/zoho/auth/route.ts that builds the Zoho authorization URL with scope ZohoCRM.modules.ALL, access_type=offline, the ZOHO_CLIENT_ID and ZOHO_REDIRECT_URI from process.env, and redirects to it. Second: app/api/zoho/callback/route.ts that extracts the code param, POSTs to Zoho's token endpoint to exchange it for tokens, logs the refresh_token to the console, and returns a JSON response with the refresh_token so it can be copied to .env.
Paste this in Bolt.new chat
1// app/api/zoho/auth/route.ts2import { NextResponse } from 'next/server';34export async function GET() {5 const clientId = process.env.ZOHO_CLIENT_ID;6 const redirectUri = process.env.ZOHO_REDIRECT_URI;7 const accountsUrl = process.env.ZOHO_ACCOUNTS_URL || 'https://accounts.zoho.com';89 if (!clientId || !redirectUri) {10 return NextResponse.json({ error: 'ZOHO_CLIENT_ID and ZOHO_REDIRECT_URI must be configured' }, { status: 500 });11 }1213 const params = new URLSearchParams({14 scope: 'ZohoCRM.modules.ALL',15 client_id: clientId,16 response_type: 'code',17 access_type: 'offline', // Required for refresh token18 redirect_uri: redirectUri,19 });2021 return NextResponse.redirect(`${accountsUrl}/oauth/v2/auth?${params}`);22}2324// app/api/zoho/callback/route.ts25// import { NextResponse } from 'next/server';26// export async function GET(request: Request) {27// const { searchParams } = new URL(request.url);28// const code = searchParams.get('code');29// if (!code) return NextResponse.json({ error: 'No authorization code received' }, { status: 400 });30// const tokenResponse = await fetch(`${process.env.ZOHO_ACCOUNTS_URL}/oauth/v2/token`, {31// method: 'POST',32// headers: { 'Content-Type': 'application/x-www-form-urlencoded' },33// body: new URLSearchParams({34// grant_type: 'authorization_code',35// client_id: process.env.ZOHO_CLIENT_ID!,36// client_secret: process.env.ZOHO_CLIENT_SECRET!,37// redirect_uri: process.env.ZOHO_REDIRECT_URI!,38// code,39// }),40// });41// const tokens = await tokenResponse.json();42// console.log('ZOHO REFRESH TOKEN (copy to .env):', tokens.refresh_token);43// return NextResponse.json({ message: 'Copy refresh_token from server logs', refresh_token: tokens.refresh_token });44// }Pro tip: You only need to complete the OAuth flow once. After copying the refresh_token to your .env file, delete or disable the /api/zoho/auth and /api/zoho/callback routes — they expose your ability to reauthorize and are not needed in production.
Expected result: After deploying and visiting /api/zoho/auth, you are redirected to Zoho's consent screen. After approving, the callback route logs the refresh token. Copy it to ZOHO_REFRESH_TOKEN in your .env file.
Fetch CRM Records from Zoho Modules
Fetch CRM Records from Zoho Modules
Zoho CRM's REST API v6 uses a consistent module-based pattern. Every module endpoint follows the same structure: `GET /crm/v6/{ModuleName}` for listing records, `GET /crm/v6/{ModuleName}/{id}` for a single record, `POST /crm/v6/{ModuleName}` for creating, `PUT /crm/v6/{ModuleName}/{id}` for updating, and `DELETE /crm/v6/{ModuleName}/{id}` for deleting. Module names are case-sensitive and use title case: Contacts, Leads, Deals, Accounts, Tasks, Events. For listing records, useful query parameters include: `fields` (comma-separated field API names to return), `sort_by` (field to sort on), `sort_order` (asc or desc), `per_page` (1-200, default 200), `page` (for pagination), and `modified_since` (ISO 8601 timestamp for delta syncs). The response wraps records in a `data` array: `{ data: [{id: '...', Field_Name: 'value', ...}], info: {count, more_records, page} }`. To get field API names for a module, call `GET /crm/v6/settings/fields?module=Contacts`. This returns the metadata for every field including the API name, data type, and whether it is required. API names for standard fields are straightforward: `First_Name`, `Last_Name`, `Email`, `Phone`, `Account_Name`. Custom fields have the prefix `Custom_Field_Name` or organization-specific prefixes. For the Deals module, the most important fields for a pipeline view are: `Deal_Name`, `Stage`, `Amount`, `Closing_Date`, `Account_Name`, `Owner`, `Probability`. The `Stage` field values correspond to your configured pipeline stages in Zoho — the default stages are Qualification, Needs Analysis, Value Proposition, Id. Decision Makers, Perception Analysis, Proposal/Price Quote, Negotiation/Review, Closed Won, and Closed Lost. To search for specific records, use `GET /crm/v6/{Module}/search?criteria=(Field_Name:equals:{value})`. Multiple criteria use AND: `(Email:equals:test@example.com)AND(Last_Name:equals:Smith)`. The search endpoint supports equals, contains, starts_with, and between operators.
Create Next.js API routes for Zoho CRM data. Build /api/zoho/contacts/route.ts that fetches contacts (fields: Full_Name, Email, Phone, Account_Name, Lead_Source, Created_Time) with optional search query param. Build /api/zoho/deals/route.ts that fetches deals grouped by stage (fields: Deal_Name, Stage, Amount, Closing_Date, Account_Name, Owner, Probability) and returns both the deals array and a stages summary with total value per stage. Both routes use the zohoFetch helper from lib/zoho.ts.
Paste this in Bolt.new chat
1// app/api/zoho/deals/route.ts2import { NextResponse } from 'next/server';3import { zohoFetch } from '@/lib/zoho';45interface ZohoDeal {6 id: string;7 Deal_Name: string;8 Stage: string;9 Amount: number | null;10 Closing_Date: string | null;11 Account_Name?: { name: string; id: string };12 Owner?: { name: string; id: string };13 Probability: number | null;14}1516interface ZohoResponse<T> {17 data: T[];18 info: { count: number; more_records: boolean; page: number };19}2021export async function GET() {22 try {23 const data = await zohoFetch<ZohoResponse<ZohoDeal>>(24 'Deals?fields=Deal_Name,Stage,Amount,Closing_Date,Account_Name,Owner,Probability&sort_by=Stage&sort_order=asc&per_page=200'25 );2627 const deals = data.data || [];2829 // Group deals by stage and calculate totals30 const stageMap = new Map<string, { deals: ZohoDeal[]; total: number }>();31 for (const deal of deals) {32 const stage = deal.Stage || 'Unknown';33 if (!stageMap.has(stage)) stageMap.set(stage, { deals: [], total: 0 });34 const entry = stageMap.get(stage)!;35 entry.deals.push(deal);36 entry.total += deal.Amount || 0;37 }3839 const stages = Array.from(stageMap.entries()).map(([name, { deals: stageDeals, total }]) => ({40 name,41 dealCount: stageDeals.length,42 totalValue: total,43 deals: stageDeals,44 }));4546 const totalPipelineValue = deals47 .filter((d) => !['Closed Won', 'Closed Lost'].includes(d.Stage))48 .reduce((sum, d) => sum + (d.Amount || 0), 0);4950 return NextResponse.json({51 deals,52 stages,53 summary: {54 total: deals.length,55 totalPipelineValue,56 wonCount: deals.filter((d) => d.Stage === 'Closed Won').length,57 lostCount: deals.filter((d) => d.Stage === 'Closed Lost').length,58 },59 });60 } catch (err) {61 const message = err instanceof Error ? err.message : 'Failed to fetch deals';62 return NextResponse.json({ error: message }, { status: 500 });63 }64}Pro tip: Zoho CRM rate limits are 200 API calls per minute on Standard and Professional plans, and 400 on Enterprise. For dashboards with multiple sections, batch your data fetching with Promise.all to make concurrent requests rather than sequential ones — this stays within the per-minute window while being much faster.
Expected result: The deals API route returns all deals grouped by pipeline stage with total values. The contacts route returns a searchable list. Both handle the token refresh automatically via the zohoFetch helper.
Create and Update CRM Records
Create and Update CRM Records
Creating and updating records in Zoho CRM follows a consistent pattern: POST to `/crm/v6/{Module}` with a JSON body containing a `data` array of record objects. Yes, even creating a single record requires wrapping it in an array — this is Zoho's design for supporting bulk operations. The response similarly wraps results in a `data` array with status objects indicating success or failure for each record. For creating a Contact, the required fields are typically just `Last_Name`. Most other fields like `First_Name`, `Email`, `Phone`, and `Account_Name` are optional at the API level even if required in your business logic. The response for a successful creation includes the new record's `id` and `details` — always capture and return this ID so you can reference the record in subsequent operations. The duplicate check flow is important for lead capture forms. Use the search endpoint before creation: `GET /crm/v6/Leads/search?criteria=(Email:equals:{email})`. If results come back, return the existing record ID instead of creating a duplicate. Zoho CRM also has built-in duplicate prevention that can be configured per module, but checking before insertion gives you better control in the application layer. For updates, send a PUT request to `/crm/v6/{Module}/{id}` with only the fields you want to change in the data array — you do not need to include unchanged fields. Zoho performs a partial update. The response again wraps the result in a data array with a status object. For bulk operations, you can include up to 100 records in the data array of a single POST or PUT request. This is useful for sync operations where you are creating or updating many records at once from an external data source.
Add a record creation API route at app/api/zoho/contacts/create/route.ts. Accept POST with { firstName, lastName, email, phone, company, leadSource }. First search for existing contact with the same email. If found, return { duplicate: true, id }. If not, create a new Contact record via Zoho CRM API and return { created: true, id, name }. Also add /api/zoho/deals/create/route.ts that accepts POST with { dealName, stage, amount, closingDate, accountName } and creates a Deal record. Both use the zohoFetch helper.
Paste this in Bolt.new chat
1// app/api/zoho/contacts/create/route.ts2import { NextResponse } from 'next/server';3import { zohoFetch } from '@/lib/zoho';45interface ZohoSearchResponse {6 data?: Array<{ id: string; Full_Name: string; Email: string }>;7}89interface ZohoCreateResponse {10 data: Array<{11 code: string;12 details: { id: string };13 message: string;14 status: string;15 }>;16}1718export async function POST(request: Request) {19 const { firstName, lastName, email, phone, company, leadSource } = await request.json();2021 if (!lastName) {22 return NextResponse.json({ error: 'Last name is required' }, { status: 400 });23 }2425 try {26 // Check for duplicate by email27 if (email) {28 const existing = await zohoFetch<ZohoSearchResponse>(29 `Contacts/search?criteria=(Email:equals:${encodeURIComponent(email)})`30 ).catch(() => ({ data: [] }));3132 if (existing.data && existing.data.length > 0) {33 return NextResponse.json({34 duplicate: true,35 id: existing.data[0].id,36 name: existing.data[0].Full_Name,37 message: 'Contact with this email already exists',38 });39 }40 }4142 // Create new contact43 const result = await zohoFetch<ZohoCreateResponse>('Contacts', {44 method: 'POST',45 body: JSON.stringify({46 data: [47 {48 First_Name: firstName || '',49 Last_Name: lastName,50 Email: email || undefined,51 Phone: phone || undefined,52 Account_Name: company ? { name: company } : undefined,53 Lead_Source: leadSource || undefined,54 },55 ],56 }),57 });5859 const record = result.data?.[0];60 if (record?.status !== 'success') {61 throw new Error(record?.message || 'Contact creation failed');62 }6364 return NextResponse.json({65 created: true,66 id: record.details.id,67 message: `Contact ${firstName ? firstName + ' ' : ''}${lastName} created successfully`,68 });69 } catch (err) {70 const message = err instanceof Error ? err.message : 'Failed to create contact';71 return NextResponse.json({ error: message }, { status: 500 });72 }73}Pro tip: Zoho CRM returns a 200 HTTP status even when a record creation fails — always check the status field in each element of the data array in the response. A failed creation returns status: 'error' with a details object explaining the reason.
Expected result: The contact creation endpoint successfully creates contacts in Zoho CRM and returns the new record ID. The duplicate detection prevents creating the same contact twice by email.
Common use cases
CRM Pipeline Dashboard
Build a visual sales pipeline showing deals grouped by stage (Qualification, Needs Analysis, Proposal, Negotiation, Closed Won, Closed Lost). Display each deal's name, amount, expected close date, and assigned owner. Calculate pipeline value by stage and show win rate. A custom dashboard gives sales managers a quick view of the pipeline without navigating Zoho's interface.
Build a Zoho CRM pipeline dashboard. Create a Next.js API route at /api/zoho/deals that fetches all open deals from Zoho CRM using the Deals module. Return deal fields: Deal_Name, Stage, Amount, Closing_Date, Account_Name, Owner, Probability. Build a React kanban-style view grouping deals by stage with total pipeline value per stage. Show each deal as a card with the deal name, amount formatted as currency, and closing date. Store ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, and ZOHO_REFRESH_TOKEN in process.env.
Copy this prompt to try it in Bolt.new
Contact and Lead Management View
Display and manage contacts and leads in a unified view that replaces repetitive navigation in Zoho's default interface. Search contacts by name or company, filter leads by lead source or status, and update records without switching context. Ideal for sales reps who want a streamlined interface for their daily contact management workflow.
Create a contact management page using Zoho CRM API. Build a Next.js API route at /api/zoho/contacts that fetches contacts with fields: Full_Name, Email, Phone, Account_Name, Lead_Source, Created_Time. Support a search query param that uses Zoho's search API with criteria=(Full_Name:contains:{query}). Build a React table with search input, sortable columns, and a click-to-expand row for contact details. Add a Create Contact form that POSTs to /api/zoho/contacts/create. Use ZOHO_REFRESH_TOKEN from process.env.
Copy this prompt to try it in Bolt.new
Lead Capture and Auto-Create in Zoho CRM
Connect a Bolt-built lead capture form directly to Zoho CRM, automatically creating Lead records when a prospect fills out a form on your website. Include UTM parameter capture, lead source assignment, and optional duplicate detection before creation. Eliminates manual data entry and ensures every lead lands in the CRM immediately.
Build a lead capture form that creates records in Zoho CRM. Create a Next.js API route at /api/zoho/leads/create that accepts POST with { firstName, lastName, email, company, phone, leadSource, utmSource, utmMedium, utmCampaign }. Call Zoho CRM API to create a Lead record. First check for duplicates by searching for the email using Zoho's search criteria. If duplicate found, return { duplicate: true, existingId }. If not, create the record and return { created: true, id }. Build a clean contact form component that calls this endpoint on submit.
Copy this prompt to try it in Bolt.new
Troubleshooting
Token refresh fails with 'invalid_code' or 'invalid_client' error
Cause: The ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, or ZOHO_REFRESH_TOKEN values are incorrect. This also occurs when using credentials from a different Zoho data center than the one your account uses.
Solution: Double-check all three values in your .env file. Verify you are using the correct data center base URL (ZOHO_ACCOUNTS_URL). For EU accounts, use https://accounts.zoho.eu. If the refresh token is invalid, re-run the OAuth flow at /api/zoho/auth to generate a new one.
API calls return 'AUTHENTICATION_FAILURE' even though the token refresh succeeds
Cause: The Zoho API domain does not match the data center. If your account is on the EU data center, API calls must go to www.zohoapis.eu, not www.zohoapis.com.
Solution: Set ZOHO_API_URL in your .env to match your data center: https://www.zohoapis.eu/crm/v6 for EU, https://www.zohoapis.in/crm/v6 for India, https://www.zohoapis.com.au/crm/v6 for Australia.
1// In lib/zoho.ts, make the API URL configurable:2const ZOHO_API_URL = process.env.ZOHO_API_URL || 'https://www.zohoapis.com/crm/v6';OAuth callback never fires — the browser redirects to Zoho but never comes back to the app
Cause: The redirect URI in the OAuth request does not exactly match the URI registered in Zoho Developer Console, or the redirect URI points to Bolt's WebContainer preview URL which cannot receive the OAuth redirect.
Solution: Deploy to Netlify or Bolt Cloud first. Register the deployed URL as the redirect URI in Zoho Developer Console (api-console.zoho.com). Ensure ZOHO_REDIRECT_URI in your .env exactly matches the registered URI including the path — even a trailing slash difference causes a mismatch.
Module not found error or empty data when fetching a specific Zoho module
Cause: The module name is misspelled or uses the wrong case. Zoho module names are case-sensitive and must use the API name, not the display name.
Solution: Use the exact module API names: Contacts, Leads, Deals, Accounts, Tasks, Events, Calls, Activities. To list all available modules and their API names, call GET /crm/v6/settings/modules — this returns every module with its api_name field.
1// Correct module names (case-sensitive):2// Contacts, Leads, Deals, Accounts, Tasks, Events, Calls3// Wrong: contacts, lead, Deal, account4// Get all modules: zohoFetch('settings/modules')Best practices
- Cache Zoho access tokens in memory for up to 55 minutes — they expire at 60 minutes and refreshing on every API call wastes quota and adds latency to every request.
- Store ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, and ZOHO_REFRESH_TOKEN as server-side environment variables without the NEXT_PUBLIC_ prefix — these are OAuth credentials that must never reach the client.
- Use the fields query parameter to fetch only the columns your UI needs — Zoho's default response includes all fields for a module, which can be a large payload for modules with many custom fields.
- Handle the OAuth setup step during initial deployment: complete the auth flow once to capture the refresh token, then the app runs autonomously without requiring user re-authorization.
- Check the status field inside Zoho's response data array on create and update operations — Zoho returns HTTP 200 for both successes and failures, embedding the error in the response body.
- For production dashboards serving multiple users, implement per-user OAuth tokens rather than a shared system token — each user connects their own Zoho CRM account through the OAuth flow.
- Rate limit awareness: the free Zoho CRM plan has significantly lower API call limits than paid plans — implement caching on frequently accessed data to avoid hitting limits during development.
- Use the search endpoint with specific criteria for exact record lookups rather than fetching all records and filtering client-side — this reduces payload size and respects Zoho's rate limits.
Alternatives
HubSpot offers a more generous free tier with no user limit and simpler API key authentication for development, making it easier to integrate quickly without OAuth setup.
Salesforce is the enterprise CRM standard with deeper API capabilities, but has no free tier and significantly more complex OAuth and API setup than Zoho.
Pipedrive is optimized specifically for sales pipeline management and offers simpler API token authentication without OAuth complexity, ideal for teams focused on deal tracking.
Freshsales (by Freshworks) offers a free plan with API access and uses API key authentication similar to Zoho but without the OAuth flow complexity.
Frequently asked questions
Does Bolt.new have a native Zoho CRM integration?
No — Bolt.new does not have a native Zoho CRM connector. The integration uses Next.js API routes to call Zoho's REST API with OAuth 2.0 authentication. Bolt's AI can generate the required OAuth flow and data fetching routes from a prompt, making setup manageable even without a native connector.
Can I use the Zoho CRM API with a free Zoho account?
Yes — the Zoho CRM free plan allows up to 3 users and includes API access with limited call quotas (typically 250-1000 API calls per day). This is sufficient for development and small-scale use. For production applications with heavier API usage, paid plans start at $14/user/month and significantly increase the call limit.
Why does the OAuth redirect not work in Bolt's WebContainer preview?
Zoho's OAuth flow sends the user to Zoho's authorization page and then redirects back to your app's callback URL. This redirect must reach a real server that can receive the HTTP request. Bolt's WebContainer preview URL is session-specific and not a stable public endpoint. Deploy to Netlify or Bolt Cloud first, register that URL as the redirect URI, complete the OAuth flow once, and store the resulting refresh token in your environment variables.
How do I handle Zoho CRM API rate limits?
Zoho CRM rate limits are per-minute and vary by plan (200 calls/minute on Standard, 400 on Enterprise). For dashboard applications, implement response caching on your API routes — cache contact and deal lists for 2-5 minutes since CRM data rarely changes faster than that. Use the modified_since parameter for incremental syncs rather than fetching all records on every request.
Does Zoho CRM's refresh token expire?
Zoho refresh tokens do not have a fixed expiry but will become invalid if not used for an extended period (typically 30+ days of inactivity) or if the user revokes access in Zoho's connected apps settings. For production applications, implement monitoring that alerts you if a token refresh fails so you can re-authorize before the application breaks.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation