CMS + Next.js: Client-Side Rendering

Use a browser component for interactive search while keeping erxes credentials and publication rules on the website's server. Complete Setup and add the posts.ts helper from SSR Fetching first.

Create a fixed post-search endpoint

The browser supplies only a search string. The server controls the GraphQL document, portal credential, publication filter, returned fields, and result limit.

// app/api/posts/route.ts
import { NextResponse } from "next/server";
import { getPublishedPosts } from "@/lib/erxes/posts";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(request: Request) {
  const params = new URL(request.url).searchParams;
  const query = params.get("query") ?? "";

  if (
    query.length > 100 ||
    params.getAll("query").length > 1 ||
    [...params.keys()].some(key => key !== "query")
  ) {
    return NextResponse.json({ error: "Invalid search" }, { status: 400 });
  }

  try {
    const posts = await getPublishedPosts(query.trim());
    return NextResponse.json(
      { posts },
      { headers: { "Cache-Control": "no-store" } },
    );
  } catch {
    return NextResponse.json(
      { error: "Content is temporarily unavailable" },
      { status: 502 },
    );
  }
}

This endpoint exposes only the published-post query. It does not accept an upstream URL, raw GraphQL, a portal ID, or a status override. A generic GraphQL forwarding endpoint would let visitors use the server credential for operations the website did not intend to expose.

Apply the website's normal request limits when deploying public search. The erxes upstream timeout is set in the Setup helper.

Add the search component

The component waits briefly after typing, aborts superseded requests, and shows loading, empty, and error states.

// app/blog/_components/post-search.tsx
"use client";

import { useEffect, useState } from "react";
import type { PostSummary } from "@/lib/erxes/posts";

export function PostSearch() {
  const [query, setQuery] = useState("");
  const [posts, setPosts] = useState<PostSummary[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");
  const [attempt, setAttempt] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    let active = true;
    setLoading(true);
    setError("");
    setPosts([]);

    const timer = setTimeout(async () => {
      try {
        const response = await fetch(
          `/api/posts?query=${encodeURIComponent(query)}`,
          { signal: controller.signal },
        );
        if (!response.ok) throw new Error("Search failed");
        const data: { posts: PostSummary[] } = await response.json();
        if (active) setPosts(data.posts);
      } catch {
        if (active) setError("Search is unavailable. Please try again.");
      } finally {
        if (active) setLoading(false);
      }
    }, 300);

    return () => {
      active = false;
      clearTimeout(timer);
      controller.abort();
    };
  }, [query, attempt]);

  return (
    <section aria-label="Search posts">
      <label htmlFor="post-search">Search published posts</label>
      <input
        id="post-search"
        type="search"
        maxLength={100}
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <div aria-live="polite" aria-busy={loading}>
        {loading ? <p>Searching…</p> : null}
        {error ? <p role="alert">{error}</p> : null}
        {!loading && !error && posts.length === 0 ? <p>No posts found.</p> : null}
      </div>
      {error && !loading ? (
        <button type="button" onClick={() => setAttempt(value => value + 1)}>
          Retry search
        </button>
      ) : null}
      <ul>
        {posts.map(post => (
          <li key={post._id}>
            {post.slug ? (
              <a href={`/blog/${encodeURIComponent(post.slug)}`}>
                {post.title || "Untitled post"}
              </a>
            ) : null}
          </li>
        ))}
      </ul>
    </section>
  );
}

The import type is erased from browser output; it does not import the server helper at runtime. For a larger application, place shared response types in a dedicated module containing no server code.

Render <PostSearch /> from the relevant page. The browser requests its own origin's /api/posts; no erxes credential is included.

Existing Apollo applications

You can retain Apollo Client for an existing application, but a browser-accessible GraphQL endpoint must enforce its allowed operations and validate inputs. Forwarding arbitrary queries with a hidden token does not provide that restriction. Pin the Apollo version used by the website and follow its matching React integration documentation.

Signed-in users and mutations

Reactions, account data, and form submissions need their own authorized endpoints and input validation. A portal token identifies the website; it does not identify a signed-in person. The gateway middleware handles portal-user authentication separately.

The cp* prefix is not a read-only guarantee. Do not add mutation forwarding to the search endpoint.

Check it works

Test normal search, no matches, rapid typing, a query over 100 characters, an unknown parameter, and an unavailable gateway. Confirm drafts never appear and browser requests contain no portal or Apps token.

Review CMS Migration when replacing an older direct-to-gateway browser integration.

Troubleshooting

  • 502 from /api/posts: the upstream erxes call failed; check the gateway URL, token validity, and the 10-second timeout in the Setup helper.
  • 400 on a valid-looking search: the endpoint rejects duplicate or unknown parameters and queries over 100 characters by design.
  • Every search returns empty: the portal identified by the token has no matching published posts; check clientPortalId on the records and status.
  • A credential visible in browser requests: the browser should only call its own origin. Audit for a NEXT_PUBLIC_* variable or a next.config env block exposing the token.
Was this helpful?