Using Filestack React in the Next.js App Router comes down to one decision: where the client boundary goes. The package ships its own 'use client' directive, which makes its components client components but does not let a Server Component hand them callbacks. That failure shows up when a page renders rather than when the project builds.
The working version is the App Router app in filestack-snippets, built against Next.js 15.5 and React 19.2. For the shortest possible version first, the React, Next.js and plain HTML setup guide gets an upload running in under twenty lines.
Key takeaways
- The package’s own
'use client'does not let a Server Component pass it callbacks. - Put
FilestackProviderin its own client file so no function crosses the boundary. - The API key needs the
NEXT_PUBLIC_prefix; the app secret never does. - Gate every picker behind state, because it opens the moment it renders.
- Delete the v6 workarounds:
next/dynamic,transpilePackages, and manual container ids.
Why the directive is not enough
Both packages are needed, since v7 takes filestack-js as a peer dependency:
npm install filestack-react@^7.0.1 filestack-js@^4.0.1
v7 adds 'use client' to the top of both the ESM and CJS builds. That makes PickerOverlay, PickerInline, PickerDropPane and FilestackProvider client components. Rendering one from a Server Component is allowed, and works.
What is not allowed is passing a function to one from a Server Component. FilestackProvider takes onUploadDone, onError and onSuccess, all functions, and functions cannot be serialized across the boundary. Declaring the provider directly in layout.tsx fails at render time with Functions cannot be passed directly to Client Components, and the error names the prop that carried the function.
Put the provider in its own client component
// app/providers.tsx
'use client';
import { FilestackProvider } from 'filestack-react';
import type { PickerResponse } from 'filestack-react';
import type { ReactNode } from 'react';
export default function Providers({ children }: { children: ReactNode }) {
const onUploadDone = (result: PickerResponse) => {
console.log('uploaded', result.filesUploaded.map((file) => file.handle));
};
return (
<FilestackProvider
apikey={process.env.NEXT_PUBLIC_FILESTACK_API_KEY}
pickerOptions={{ accept: ['image/*'], maxFiles: 3 }}
onUploadDone={onUploadDone}
>
{children}
</FilestackProvider>
);
}
The callbacks are defined inside a file that is already on the client, so nothing crosses the boundary. The layout stays on the server:
// app/layout.tsx
import type { ReactNode } from 'react';
import Providers from './providers';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
The key goes in NEXT_PUBLIC
In .env.local:
NEXT_PUBLIC_FILESTACK_API_KEY=your_key
The NEXT_PUBLIC_ prefix is required, since the picker runs in the browser and an unprefixed variable is server-only. This is safe. The API key names your application rather than authenticating it, and it is designed to be readable in a client bundle. You can create a free API key and paste it straight in.
What must never carry that prefix is the app secret. It signs policies, it belongs in a route handler, and prefixing it would publish it in the JavaScript you ship.
Keep the picker behind an interaction
Every picker component opens as soon as it renders, so the button that opens it needs its own state, and that makes it a client component too:
// app/upload-button.tsx
'use client';
import { useState } from 'react';
import { PickerOverlay } from 'filestack-react';
export default function UploadButton() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Upload</button>
{open && <PickerOverlay onUploadDone={() => setOpen(false)} />}
</>
);
}
The page that uses it stays on the server and ships no JavaScript of its own:
// app/page.tsx
import UploadButton from './upload-button';
export default function Page() {
return (
<main>
<h1>Upload</h1>
<UploadButton />
</main>
);
}
This arrangement is what keeps the route statically prerenderable. In the example repo the build reports the page as static, at 226 KB first load JavaScript with the picker included.
What you no longer need
The full set of changes is in the React SDK v7.0.0 release notes.
Integrations written against v6 usually carry workarounds that v7 makes redundant. On the App Router the common ones are a next/dynamic import with ssr: false wrapped around the picker, a transpilePackages: ['filestack-react'] entry in next.config, and manually generated container ids passed in as props to stop hydration warnings.
All three can go. The export map is corrected, so the bundler resolves the package without help. Container ids come from useId(), which produces the same value on the server and the client by design. The example repo’s next.config.mjs is empty for exactly this reason.
Remove them one at a time and rebuild. If the picker disappears after you remove the dynamic import, the component is rendering without the state gate that the dynamic import was supplying. Add the useState gate shown in “Keep the picker behind an interaction”.
Uploading from a Server Action
The picker uploads directly from the browser to Filestack, so the file never passes through your Next.js server. What you usually want on the server is the handle, once the upload finishes.
// app/attachment-picker.tsx
'use client';
import { PickerOverlay } from 'filestack-react';
import type { PickerResponse } from 'filestack-react';
import { saveAttachment } from './actions';
export default function AttachmentPicker() {
return (
<PickerOverlay
onUploadDone={(result: PickerResponse) => {
for (const file of result.filesUploaded) {
saveAttachment(file.handle, file.filename);
}
}}
/>
);
}
saveAttachment is an ordinary Server Action. Calling one from a client component is the supported direction, and it keeps the database write on the server without routing the bytes through it. Validate the handle there rather than trusting the filename, since everything in that callback originated in the browser.
Serving the file afterwards
The handle addresses the file on the CDN, and transformations are path segments in front of it:
https://cdn.filestackcontent.com/resize=width:600/HANDLE
That means next/image can point straight at a transformed URL, and the resizing happens before the bytes leave the CDN rather than in your Node process. Add cdn.filestackcontent.com to images.remotePatterns in next.config and the loader will accept it.
No API key belongs in a delivery URL, because the handle already identifies the application. The route those bytes take to the browser is described in the guide to CDN delivery and edge caching.
Where to put the boundary in a real application
The example above has one provider at the root, which is the right default and not always the right answer.
A root provider is simplest when uploading appears on several routes and the configuration is the same everywhere. The cost is that every route in the application now renders a client component in its tree, even the marketing pages that will never show a picker. The provider itself is tiny, so the added bundle weight is small, but the root layout is no longer purely server-rendered.
The alternative is to push the provider down to the route group that needs it. In an application where uploads only happen inside a dashboard, wrapping app/(dashboard)/layout.tsx instead of app/layout.tsx keeps the public routes free of it entirely, and the configuration can then reflect what that section actually accepts.
The third option is no provider at all. Passing apikey and the callbacks directly to each picker is more repetitive and perfectly valid, and it suits an application with exactly one upload screen. The provider earns its place at around the third screen, or the first time somebody changes the API key and has to find every component that hardcoded it.
None of these change how the picker behaves. They change how much of the route tree is client territory.
Debugging the boundary errors
Three errors come up repeatedly and each points somewhere specific.
Functions cannot be passed directly to Client Components. The provider or a picker is being rendered from a server file with a callback prop. Move it into a file with 'use client' at the top. The error names the prop, which tells you which callback to chase.
useState only works in a Client Component. A component that gates the picker behind state is missing its own directive. The directive is per file and it is not inherited from an importing file, so every file that uses hooks needs it.
Hydration failed because the server rendered HTML did not match. In v7 this is rarely the picker, since ids come from useId(). Check whether something around it renders a date, a random value, or reads window during render.
Checking it works
Run npm run build and read the route table. The upload page should be marked static. If it is marked dynamic, something in the tree is reading request-time data. Check first whether the provider moved back into a server file.
Then load the page with JavaScript disabled. You should see the heading and the button, and no picker markup, which confirms the picker is not in the server render. Enable JavaScript, click the button, and the picker should open once.
Large files behave on the App Router exactly as they do anywhere else, since the upload leaves the browser directly. If your users bring multi-gigabyte files, pausing and resuming large uploads covers the chunking that sits underneath.
FAQ
Why does the build pass but the page fail?
Because the boundary violation is a render-time error, not a compile-time one. A Server Component may render a client component, so nothing looks wrong until a callback prop tries to serialize across the boundary and the page throws.
Is it safe to expose the API key with NEXT_PUBLIC?
Yes. The key names your application rather than authenticating it, and the picker needs it in the browser. The value that must never be prefixed is the app secret, which signs policies and belongs in a route handler.
Should the provider go in the root layout?
Only if uploading appears across several routes with the same configuration. If uploads are confined to a dashboard, wrap that route group’s layout instead and leave the public routes free of it. One upload screen needs no provider at all.
Do I still need next/dynamic or transpilePackages?
No. v7 corrects the export map and derives container ids with useId(), so the bundler resolves the package unaided and hydration matches. If the picker vanishes after you remove the dynamic import, it is missing the useState gate, not the wrapper.
Joshua is a web developer with over 4 years of experience building responsive, high-performance websites and web applications. Currently working as an AI Automation Specialist, he combines modern web development with automation to create efficient, scalable digital solutions. He shares practical insights on WordPress, web development, and emerging technologies.
Read More →