Mixing SSR and Non-SSR Pages with Next.js + Supabase
Published on:
While building this blog using Next.js and Supabase, I ran into a small but confusing issue when mixing SSR pages and non-SSR (client-rendered) pages that both talk to Supabase.
It took me a bit to figure out what was going on, so I decided to write this down in case it helps someone else.
TL;DR
- You need different Supabase clients for SSR pages and client-side pages
- Use
createServerClienton the server andcreateBrowserClienton the client - If you use a server-side API inside a client component, you’ll get an error
- To avoid this, split APIs into server-side and client-side files
- This is fine for small projects; for larger ones, consider Server Components or Route Handlers / Server Actions
The Problem
When using Supabase with Next.js, you need to use different Supabase clients depending on whether your code runs on the server or in the browser.
For example, most blog post detail pages are SSR pages, so I assumed it made sense to fetch post data directly from page.tsx.
Following the official Supabase Quick Start for Next.js, I used the server-side Supabase client.
import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; export async function createClient() { const cookieStore = await cookies(); return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, ...
Using this client, I created a simple API function called getPostById.
ts Copy code
import { createClient } from "../../supabase/server"; export async function getPostById(id: number): Promise<Post> { const supabase = await createClient(); const { data, error } = await supabase .from("posts") .select() .eq("id", id) .single(); // Return the data... }
Then I used it in an SSR page.
const PostDetailPage = async ({ params }: PostDetailPageProps) => { const { id, lang } = await params; const post = await getPostById(Number(id)); // Render the page using post data... }
So far, everything worked as expected.
Next, I created a page for editing posts. This page is fully client-rendered. For updating data, I used the client-side Supabase client.
import { createBrowserClient } from "@supabase/ssr"; export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, ...
Using that client, I defined an updatePost API.
import { createClient } from "../../supabase/client"; export async function updatePost( id: number, post: PostFormData, ): Promise<void> { const supabase = createClient(); ...
The problem started when I tried to use both getPostById and updatePost inside a "use client" page.
"use client"; import { getPostById } from "@/lib/api/server/posts"; import { updatePost } from "@/lib/api/client/posts"; // ... export default function EditPage() { const params = useParams(); const { id } = params; const [post, setPost] = useState<any>(null); useEffect(() => { (async () => { const data = await getPostById(Number(id)); setPost(data); })(); }, [id]); return ( <UpdatePostForm initialValues={post} onSubmit={async (values) => { await updatePost(post.id, values); }} submitLabel="Update Post" /> ); }
This resulted in the following error:
Ecmascript file had an error
1 | import { createServerClient } from "@supabase/ssr";
> 2 | import { cookies } from "next/headers";
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
3 |
4 | export async function createClient() {
5 | const cookieStore = await cookies();
You're importing a component that needs "next/headers".
That only works in a Server Component.
The reason is pretty simple: I was trying to use a server-side API (getPostById) inside a client-side component.
Since that API internally uses next/headers, which only works on the server, Next.js throws an error.
The Solution
The fix was straightforward.
I created a client-side version of getPostById that uses the browser Supabase client instead.
This means having two functions with the same name in different files. But since each one is only used in either server-side or client-side code, this works fine in practice.
Copy code lib └── api ├── client │ └── posts.ts # getPostById, updatePost └── server └── posts.ts # getPostById
And on the client side, I just import the client APIs.
Copy code "use client"; import { getPostById, updatePost } from "@/lib/api/client/posts"; // ...
With this change, everything worked as expected.
Final Notes
For larger applications, this approach isn’t ideal. A more recommended pattern is:
-
Fetch data in Server Components and pass it down to Client Components as props
-
Or use Route Handlers / Server Actions as a boundary between client and server
In this case, the project is very small, so I chose simplicity over architecture perfection.
Hope this helps someone avoid the same confusion.