Skip to main content

Read and Write CloudBase Database in Next.js Server Components

In one sentence: Use @cloudbase/node-sdk to directly await db.collection().get() inside a Next.js App Router Server Component, route write operations through a Server Action ('use server') followed by revalidatePath to invalidate the cache, and keep Client Components responsible only for forms and triggering — with no awareness of the database.

Estimated time: 30 minutes | Difficulty: Advanced

Applicable Scenarios

  • Applicable: Full-stack Next.js projects that want to connect to a CloudBase database directly without setting up a separate API route layer
  • Applicable: Already using RSC (React Server Components) and want to push data fetching down into the component tree
  • Applicable: Already have a CloudBase backend (shared with a Mini Program / mobile app) and are rewriting the web side with Next.js
  • Not applicable: Pure frontend SPA / static export (output: 'export') — use add-auth-web-with-cloudbase-sdk with the client SDK instead
  • Not applicable: Mini Program business logic — use add-database-wechat-miniprogram
  • Not applicable: Deploying the entire Next.js app to Cloudflare Workers / Edge Runtime — @cloudbase/node-sdk depends on Node built-in modules and cannot run on the Edge

Prerequisites

DependencyVersion
Node.js18.17 (minimum required by Next.js 14 / 15)
next^14.x or ^15.x (App Router)
@cloudbase/node-sdkLatest (npm view @cloudbase/node-sdk version)
Deployment runtimeNode Runtime (default) — Edge Runtime is not supported

Also required:

  • A provisioned CloudBase environment ID
  • A pair of Tencent Cloud API keys (SecretId / SecretKey) — apply in the console at Access Management (CAM) with at least QcloudTCBFullAccess (or the finer-grained QcloudTCBReadWriteAccess)
  • A test collection users with its permission set to "Only creator and admin can read and write" to prevent the client SDK from bypassing the server and directly accessing the database
Keys must never reach the client

@cloudbase/node-sdk uses Tencent Cloud API keys, which carry admin-level access and must never be imported into any Client Component ('use client' file). Next.js bundles Client Component dependencies into the client-side JS, which would expose the keys in the bundle. Every @cloudbase/node-sdk import in this guide appears exclusively in Server Components, Server Actions, or Route Handlers.

Step 1: Shared Initialization in lib/cloudbase.ts

tcb.init maintains a connection pool internally; initializing it in every file wastes resources. Centralize it in a shared module with lazy initialization:

npm i @cloudbase/node-sdk

lib/cloudbase.ts:

import tcb from '@cloudbase/node-sdk';

let cached: ReturnType<typeof tcb.init> | null = null;

export function getCloudbase() {
if (!cached) {
cached = tcb.init({
env: process.env.CLOUDBASE_ENV!,
secretId: process.env.TENCENTCLOUD_SECRETID,
secretKey: process.env.TENCENTCLOUD_SECRETKEY,
});
}
return cached;
}

.env.local (local development only — do not commit):

CLOUDBASE_ENV=your-env-id
TENCENTCLOUD_SECRETID=AKIDxxxxxx
TENCENTCLOUD_SECRETKEY=xxxxxxxx

Key points:

  • No 'use server' / 'use client' directive — this is a plain ES module; which runtime imports it determines where it runs. This guide only allows the server side to import it.
  • The three environment variable names follow the Tencent Cloud convention: TENCENTCLOUD_SECRETID / TENCENTCLOUD_SECRETKEY. These are injected automatically on managed platforms like Tencent Cloud Run / Cloud Functions; use .env.local to simulate them locally.
  • For Vercel / self-hosted servers: configure the same variable names in the platform's environment variable panel.

Step 2: Directly Await the Database in a Server Component

page.tsx files in App Router are Server Components by default. They can be async, allowing a top-level await on the database that renders synchronous HTML:

app/users/page.tsx:

import { getCloudbase } from '@/lib/cloudbase';

export default async function UsersPage() {
const app = getCloudbase();
const db = app.database();

const { data } = await db.collection('users').limit(10).orderBy('createdAt', 'desc').get();

return (
<main>
<h1>User List</h1>
<ul>
{data.map((u) => (
<li key={u._id}>
{u.name}{new Date(u.createdAt).toLocaleString('en-US')}
</li>
))}
</ul>
</main>
);
}

Key points:

  • The direct await in a Server Component causes Next.js to wait for the query before sending HTML, eliminating first-paint loading flicker.
  • db.collection().get() returns { data, requestId, ... }. When only the data matters, destructure .data.
  • To run queries in parallel: const [a, b] = await Promise.all([q1.get(), q2.get()])
  • This file has no 'use client' directive — it is RSC by default and cannot use client hooks like useState / useEffect.

Step 3: Write Data with a Server Action

Place write operations in a dedicated actions.ts file with 'use server' at the top. Next.js marks this file as "always runs on the server"; when a Client Component imports it, it only receives an RPC handle — the code is never bundled into the client.

app/users/actions.ts:

'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { getCloudbase } from '@/lib/cloudbase';

export async function createUser(formData: FormData) {
const name = (formData.get('name') as string)?.trim();
if (!name) {
return { ok: false, error: 'Name cannot be empty' };
}

try {
const app = getCloudbase();
const db = app.database();
await db.collection('users').add({
data: {
name,
createdAt: Date.now(),
},
});
} catch (e: any) {
return { ok: false, error: e.message ?? 'unknown error' };
}

// Invalidate the RSC cache for the /users path so the query re-runs
revalidatePath('/users');
return { ok: true };
}

Key points:

  • 'use server' must be the very first line in the file (comments may precede it); it cannot be placed inside a function body.
  • Server Actions must be async function. Parameters and return values must be serializableFormData / primitives / plain objects are fine; functions, class instances, and Date objects are not. (Date serializes to a string, so the example uses Date.now() as a number instead.)
  • Do not throw errors to the client. The client receives a generic Next.js-wrapped error with incomplete information. Return { ok, error } instead.
  • revalidatePath('/users') invalidates all RSC caches under that path. The next request re-runs the query. Without this call, new data only appears on a hard refresh.

app/users/CreateForm.tsx (Client Component):

'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { createUser } from './actions';

const initialState = { ok: false, error: '' };

function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create'}
</button>
);
}

export function CreateForm() {
const [state, formAction] = useFormState(createUser, initialState);
return (
<form action={formAction}>
<input name="name" placeholder="Name" required />
<SubmitButton />
{state?.error && <span style={{ color: 'red' }}>{state.error}</span>}
</form>
);
}

Key points:

  • <form action={createUser}> is the React 19 / Next.js 14+ pattern; form submission goes directly to the Server Action without needing onSubmit + fetch.
  • useFormState feeds the Server Action's return value into React state so error messages can be rendered.
  • useFormStatus provides the pending state during submission to disable the button. Note: it must be used in a child component of <form>, not in the same component as useFormState.
  • Starting with React 19, useFormState is renamed useActionState and imported from react instead of react-dom. Next.js 15 + React 19 uses the new name.

Mount the form in page.tsx:

import { getCloudbase } from '@/lib/cloudbase';
import { CreateForm } from './CreateForm';

export default async function UsersPage() {
const { data } = await getCloudbase().database().collection('users').limit(10).orderBy('createdAt', 'desc').get();

return (
<main>
<h1>User List</h1>
<CreateForm />
<ul>
{data.map((u) => (
<li key={u._id}>{u.name}</li>
))}
</ul>
</main>
);
}

Step 4: Refresh Caches with revalidatePath / revalidateTag

Next.js RSC performs "request-level deduplication" and "Data Cache" by default: identical fetches within a single render are deduplicated, and pages can be statically generated at build time. revalidatePath and revalidateTag are the two mechanisms for actively invalidating that cache.

// Path-level: invalidate all render caches for /users
revalidatePath('/users');

// Dynamic route invalidation: the second argument specifies the type
revalidatePath('/users/[id]', 'page');

// Tag-level: bulk invalidation
import { revalidateTag } from 'next/cache';
revalidateTag('users');

To associate a database query with a tag, wrap it in unstable_cache:

// lib/users.ts
import { unstable_cache } from 'next/cache';
import { getCloudbase } from './cloudbase';

export const getUsers = unstable_cache(
async () => {
const { data } = await getCloudbase().database().collection('users').limit(10).get();
return data;
},
['users-list'],
{ tags: ['users'], revalidate: 60 }, // Auto-stale after 60s, or actively invalidated by revalidateTag('users')
);

After a write in a Server Action, call revalidateTag('users') to simultaneously invalidate all queries tagged with users.

unstable_cache is not needed for simple cases

If only one page queries users, revalidatePath alone is sufficient. Reaching for unstable_cache adds complexity. Use it when multiple pages share the same query, or when you need time-based revalidation.

Step 5: Configure Permission Rules

CloudBase database has 6 built-in permission modes. Server-side admin operations bypass collection permissions (API keys carry admin authority), but to prevent the client SDK from bypassing the server entirely, set the collection to the most restrictive mode:

Console → Database → your collection → Permission Settings → select "Only creator and admin can read and write" or "Custom Security Rules":

{
"read": "auth.uid != null && doc._openid == auth.uid",
"write": "auth.uid != null && doc._openid == auth.uid"
}

For user identity verification in the business layer, Server Actions have two approaches for identifying the current user:

Option A — Manage your own user system (recommended)

Use next-auth or a custom session in Next.js. Read a cookie in the Server Action to get userId, then write it as a business field on the document:

'use server';
import { cookies } from 'next/headers';
import { getCloudbase } from '@/lib/cloudbase';

export async function createUser(formData: FormData) {
const session = cookies().get('session')?.value;
const userId = await verifySession(session); // your own verification logic
if (!userId) return { ok: false, error: 'Not logged in' };

const db = getCloudbase().database();
await db.collection('users').add({
data: { name: formData.get('name'), ownerId: userId, createdAt: Date.now() },
});
// ...
}

Option B — Use the CloudBase user system

If CloudBase users already exist (e.g., shared with a Mini Program), exchange the client token for a CloudBase identity on the server:

const app = getCloudbase();
const auth = app.auth();
const ticket = await auth.getUserInfo({ accessToken: clientToken });
// ticket.uid is the CloudBase uid

In practice, the vast majority of SaaS applications use Option A — the server holds admin access for all operations, the business layer defines who can modify what, and the database is simply a locked-down store.

Step 6: Error Boundary

Errors thrown inside a Server Component bubble up to the nearest error.tsx. You can create an error boundary for each route segment:

app/users/error.tsx:

'use client'; // error.tsx must be a Client Component

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<h2>Failed to load users</h2>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
);
}

For "not found" semantics, call notFound() directly in the Server Component to navigate to not-found.tsx:

import { notFound, redirect } from 'next/navigation';

export default async function UserDetail({ params }: { params: { id: string } }) {
const { data } = await getCloudbase().database().collection('users').doc(params.id).get();
if (!data || data.length === 0) notFound();

// No permission to view? Redirect.
if (data[0].private) redirect('/login');

return <div>{data[0].name}</div>;
}

notFound() and redirect() work by throwing a special error object that Next.js catches and handles. Do not wrap them in a try/catch — that will swallow them.

Verification

  1. Local: run npm run dev to start Next.js and visit http://localhost:3000/users. The page should render immediately with an empty list (no data yet) and the create form.
  2. Enter a name in the form and submit. Within one second, the new entry should appear in the list (revalidatePath triggered an RSC re-render).
  3. Console → Database → users collection: confirm the submitted record is present with the correct name and createdAt fields.
  4. Stop the dev server. Open the browser's Network panel and refresh the page — the users document response should be complete HTML with the data already rendered, not fetched afterward. This confirms SSR is working correctly.
  5. In the browser Console, search for TENCENTCLOUD_SECRETID or the prefix of your key (e.g., AKID). Nothing should appear. If anything does, a Client Component has mistakenly imported lib/cloudbase.ts — investigate immediately.
  6. Deploy to Vercel / Tencent Cloud Run. Configure CLOUDBASE_ENV, TENCENTCLOUD_SECRETID, and TENCENTCLOUD_SECRETKEY in the platform's environment variable panel, redeploy, and repeat steps 1–5.

Common Errors

Error / symptomCauseFix
process is not defined or Node built-in module errors in the browser Consolelib/cloudbase.ts or @cloudbase/node-sdk was imported in a Client Component ('use client' file)Move all database calls to Server Components / Server Actions; Client Components should only call Server Actions. Alternatively, add @cloudbase/node-sdk to serverExternalPackages in next.config.js to force server-only bundling.
Server Actions must be async functions from a Server Action callA non-async export exists in actions.tsAll exports in a 'use server' file must be async functions; synchronous functions are not allowed.
Cannot pass non-serializable parameter from a Server Action callA Date instance, class instance, or function was passed to an ActionUse primitives / FormData / plain objects. Replace Date with .getTime() or an ISO string.
revalidatePath('/users') was called but the page did not refreshThe path is wrong (wrong casing, or a dynamic segment is missing)The path must match the file route exactly. For dynamic routes, use revalidatePath('/users/[id]', 'page').
The edge runtime does not support Node.js after deploymentThe route file has export const runtime = 'edge', or the deployment target is an Edge Runtime platform@cloudbase/node-sdk requires Node Runtime. Remove runtime = 'edge', or switch to the CloudBase client SDK over HTTP.
Database query timeout / connect ETIMEDOUTThe deployment environment's egress IP is blocked, or the SDK is resolving to the wrong domainCloudBase defaults to the public internet and requires no IP allowlist for public cloud deployments. For private network deployments, confirm that tcb-api.tencentcloudapi.com is reachable.
Client SDK can read from the collection directly in the ConsoleCollection permission is too permissive ("All users can read")Change to "Only creator and admin can read and write" or a custom rule. Server admin is unaffected.
User identity lost — user state missingTrying to maintain user login state inside @cloudbase/node-sdkThe node SDK carries admin identity and does not maintain user state. Manage user state via Next.js cookies / session. See Step 5, Option A.

For error code definitions, see error-code.

Next Steps