Skip to content
HELiX

Next.js 15 App Router Integration

apps/docs/src/content/docs/framework-integration/nextjs Click to copy
Copied! apps/docs/src/content/docs/framework-integration/nextjs

Next.js 15 App Router introduces unique challenges for web components: Server Components render on the server where custom element APIs don’t exist, and Lit’s browser-only lifecycle requires careful boundary placement. This guide covers every pattern you need to integrate HELiX reliably.

Terminal window
npm install @helixui/library

If you want React wrapper components with full prop typing (optional), also install @helixui/react:

Terminal window
npm install @helixui/react

Custom element registration (customElements.define) is a browser-only API. Any module that imports HELiX components must be a Client Component.

  • Any file that imports @helixui/library or individual components
  • Any component that listens to HELiX custom events (hx-click, hx-change, etc.)
  • Any component that holds a ref to a HELiX element
  • Server Components that only render HELiX tag names as JSX — the HTML is valid, the element upgrades on the client
  • Layout files that only pass children through

Register all components once in a dedicated Client Component at the root:

components/helix-loader.tsx
'use client';
import { useEffect } from 'react';
export function HelixLoader() {
useEffect(() => {
// Dynamically import so the registration only runs in the browser
import('@helixui/library');
}, []);
return null;
}
// app/layout.tsx (Server Component — no 'use client' needed here)
import { HelixLoader } from '@/components/helix-loader';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<HelixLoader />
{children}
</body>
</html>
);
}

With this pattern, Server Components can render HELiX tag names freely. The custom element upgrades after hydration without warnings.

Pattern: Client Boundary per Interactive Component

Section titled “Pattern: Client Boundary per Interactive Component”

For interactive components that need event handlers, create a thin Client Component wrapper:

components/save-button.tsx
'use client';
import { useRef, useEffect } from 'react';
import '@helixui/library/components/hx-button';
interface SaveButtonProps {
onSave: () => void;
label?: string;
}
export function SaveButton({ onSave, label = 'Save' }: SaveButtonProps) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.addEventListener('hx-click', onSave);
return () => el.removeEventListener('hx-click', onSave);
}, [onSave]);
return <hx-button ref={ref} variant="primary">{label}</hx-button>;
}

The parent Server Component passes data down; the Client Component handles browser events:

// app/dashboard/page.tsx (Server Component)
import { SaveButton } from '@/components/save-button';
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<SaveButton label="Save changes" onSave={async () => { 'use server'; /* ... */ }} />
</main>
);
}

Use next/dynamic with ssr: false when a component imports HELiX at module scope (outside useEffect) or relies on window/document during render:

app/some-page/page.tsx
import dynamic from 'next/dynamic';
const HeavyHelixForm = dynamic(
() => import('@/components/helix-form').then((m) => m.HelixForm),
{
ssr: false,
loading: () => <div aria-busy="true">Loading form…</div>,
}
);
export default function SomePage() {
return (
<main>
<HeavyHelixForm />
</main>
);
}

When to use ssr: false vs the HelixLoader pattern:

ApproachWhen to use
HelixLoader + useEffect importGlobal registration, components used across many pages
next/dynamic with ssr: falseHeavy page-specific components, progressive enhancement
Direct module import with 'use client'Simple interactive islands with tight coupling

HELiX ships types for every component. Add the library to your types or rely on automatic discovery via node_modules/@helixui/library/:

tsconfig.json
{
"compilerOptions": {
"strict": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

The key entries:

  • "lib": ["ES2022", "DOM", "DOM.Iterable"] — required for CustomElementRegistry, ElementInternals, and other browser APIs used by HELiX
  • "strict": true — required; HELiX types are authored under strict mode

Create src/helix.d.ts so TypeScript recognizes HELiX elements in JSX:

src/helix.d.ts
import type { HelixButton, HelixTextInput, HelixCard } from '@helixui/library';
declare global {
namespace JSX {
interface IntrinsicElements {
'hx-button': React.DetailedHTMLProps<
React.HTMLAttributes<HelixButton>,
HelixButton
> & {
variant?: 'primary' | 'secondary' | 'tertiary' | 'danger' | 'ghost' | 'outline';
'hx-size'?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
type?: 'button' | 'submit' | 'reset';
};
'hx-text-input': React.DetailedHTMLProps<
React.HTMLAttributes<HelixTextInput>,
HelixTextInput
> & {
value?: string;
placeholder?: string;
label?: string;
disabled?: boolean;
required?: boolean;
name?: string;
type?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'search' | 'number' | 'date';
error?: string;
};
'hx-card': React.DetailedHTMLProps<
React.HTMLAttributes<HelixCard>,
HelixCard
> & {
variant?: 'default' | 'featured' | 'compact';
elevation?: 'flat' | 'raised' | 'floating';
};
// Add additional components as needed
}
}
}

The attribute name is hx-size (not size) on hx-button — HELiX prefixes the size attribute to avoid clashing with the native HTML size attribute that Next.js / React may forward to other elements.

Section titled “Using @helixui/react wrappers for full typing (recommended)”

@helixui/react provides auto-generated React wrapper components for every HELiX component. Each wrapper exposes typed onHx* callback props and eliminates ref-based event listeners:

Terminal window
npm install @helixui/react
components/action-bar.tsx
'use client';
import { HxButton } from '@helixui/react';
export function ActionBar() {
return (
<HxButton variant="primary" onHxClick={() => console.log('clicked')}>
Confirm
</HxButton>
);
}

See the React Wrappers guide for the full API including typed event callbacks, ref forwarding, slots, and the complete component list.

HELiX form components use the ElementInternals API for native form participation — they work with standard HTML forms and therefore with React Server Actions.

Server Components must not import HELiX modules directly — @customElement registration is browser-only. Split the page into a Server Component (the route + action wiring) and a Client Component (the rendered form):

// app/contact/page.tsx — Server Component
import { submitContact } from './actions';
import { ContactForm } from './contact-form';
export default function ContactPage() {
return <ContactForm action={submitContact} />;
}
// app/contact/contact-form.tsx — Client Component
'use client';
import '@helixui/library/components/hx-text-input';
import '@helixui/library/components/hx-button';
export function ContactForm({ action }: { action: (fd: FormData) => void }) {
return (
<form action={action}>
<hx-text-input name="name" label="Full name" required />
<hx-text-input name="email" type="email" label="Email" required />
<hx-button type="submit" variant="primary">Send</hx-button>
</form>
);
}
app/contact/actions.ts
'use server';
export async function submitContact(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
// process...
}

Because HELiX form components participate natively in the form, FormData receives their values automatically — no manual wiring needed.

For client-side feedback during submission, combine a Server Action with useActionState:

components/contact-form.tsx
'use client';
import { useActionState } from 'react';
import { submitContact } from '@/app/contact/actions';
import '@helixui/library/components/hx-text-input';
import '@helixui/library/components/hx-button';
interface FormState {
error?: string;
success?: boolean;
}
const initialState: FormState = {};
export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, initialState);
return (
<form action={formAction}>
{state.error && <p role="alert">{state.error}</p>}
{state.success && <p role="status">Message sent!</p>}
<hx-text-input name="name" label="Full name" required />
<hx-text-input name="email" type="email" label="Email" required />
<hx-button type="submit" variant="primary" loading={isPending ? true : undefined}>
{isPending ? 'Sending…' : 'Send'}
</hx-button>
</form>
);
}
app/contact/actions.ts
'use server';
interface FormState {
error?: string;
success?: boolean;
}
export async function submitContact(
_prevState: FormState,
formData: FormData
): Promise<FormState> {
try {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
if (!name || !email) return { error: 'All fields are required.' };
// submit...
return { success: true };
} catch {
return { error: 'Submission failed. Please try again.' };
}
}

Lit components register in the browser via customElements.define. On the server, Next.js renders custom element tags as unknown HTML elements. This is correct behavior — no hydration mismatch occurs for the element tag itself.

  1. Attributes that differ between server and client render

    // Bad — Date.now() produces a different value server vs client
    <hx-card data-timestamp={Date.now()}>...</hx-card>
    // Good — derive stable values server-side
    <hx-card data-id={item.id}>...</hx-card>
  2. Conditional rendering based on browser APIs

    // Bad — window is undefined on the server
    <hx-button disabled={typeof window === 'undefined'}>...</hx-button>
    // Good — use useEffect to apply browser-dependent state after hydration
    'use client';
    const [mounted, setMounted] = useState(false);
    useEffect(() => setMounted(true), []);
    return <hx-button disabled={!mounted ? true : undefined}>...</hx-button>;
  3. Dynamic class names generated client-side

    Ensure any className applied to a wrapper element around HELiX components is deterministic on both server and client.

If you use a third-party component that wraps HELiX elements and produces unavoidable mismatch warnings, suppress only the specific element:

'use client';
export function ClientOnlyWrapper({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <>{children}</>;
}

Use sparingly — prefer fixing the root cause.

Only load the HELiX components a route actually needs. Use per-component imports instead of the full library barrel:

app/forms/page.tsx
import dynamic from 'next/dynamic';
// Only loads hx-text-input and hx-button bundles for this route
const FormPage = dynamic(() => import('@/components/contact-form'), {
ssr: false,
loading: () => <div>Loading…</div>,
});
export default function FormsRoute() {
return <FormPage />;
}

Inside contact-form.tsx, import only what you need:

'use client';
// Tree-shakeable per-component imports — import only what the route uses.
import '@helixui/library/components/hx-text-input';
import '@helixui/library/components/hx-button';

Avoid importing the full library on every page:

// Avoid unless you use most components on every route.
import '@helixui/library';

Measure current component and bundle sizes locally before optimising for a budget — run node scripts/measure-component-size.js (in the HELiX monorepo) or pnpm run check:bundle to inspect per-entry min+gz output rather than relying on a doc-time approximation.

If you encounter bundling issues with @helixui/library (ESM interop warnings), add it to transpilePackages in next.config.ts:

next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@helixui/library'],
};
export default nextConfig;

For above-the-fold HELiX components, preload the script in your layout:

app/layout.tsx
import { HelixLoader } from '@/components/helix-loader';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{/* Preload the HELiX bundle for above-the-fold components */}
<link rel="modulepreload" href="/_next/static/chunks/helix-library.js" />
</head>
<body>
<HelixLoader />
{children}
</body>
</html>
);
}

Pitfall: ReferenceError: customElements is not defined

Section titled “Pitfall: ReferenceError: customElements is not defined”

Cause: A module importing @helixui/library executed on the server (outside a Client Component or useEffect).

Solution: Ensure the import is inside useEffect or mark the file as 'use client':

// Bad — runs during server render
import '@helixui/library'; // top-level import in a Server Component
// Good — deferred to browser
'use client';
useEffect(() => { import('@helixui/library'); }, []);

Pitfall: Event handlers silently not firing

Section titled “Pitfall: Event handlers silently not firing”

Cause: HELiX uses custom events (hx-click, hx-change) that React’s synthetic event system does not forward.

Solution: Use a ref and addEventListener, or @lit/react wrappers:

'use client';
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const handler = (e: Event) => { /* ... */ };
el.addEventListener('hx-click', handler);
return () => el.removeEventListener('hx-click', handler);
}, []);
return <hx-button ref={ref}>Click me</hx-button>;

Pitfall: Form values missing from FormData

Section titled “Pitfall: Form values missing from FormData”

Cause: HELiX form components require name attribute to participate in form data.

Solution: Always provide the name attribute:

// Bad — value not included in FormData
<hx-text-input label="Email" type="email" />
// Good
<hx-text-input name="email" label="Email" type="email" required />

Pitfall: disabled="false" still disables the component

Section titled “Pitfall: disabled="false" still disables the component”

Cause: HTML boolean attributes treat any string value (including "false") as truthy. React does NOT omit the attribute when you pass disabled="false" as a string.

Solution: Use the boolean prop correctly — React omits it when false:

// Bad — disabled="false" is still truthy in HTML
<hx-button disabled="false">Submit</hx-button>
// Good — React omits the attribute when the value is false
<hx-button disabled={isDisabled}>Submit</hx-button>

Pitfall: TypeScript error on HELiX element props

Section titled “Pitfall: TypeScript error on HELiX element props”

Cause: JSX doesn’t know about custom element props without type declarations.

Solution: Add declarations to src/helix.d.ts as shown in the TypeScript section.

Pitfall: next/dynamic import resolves to undefined

Section titled “Pitfall: next/dynamic import resolves to undefined”

Cause: The dynamic import path doesn’t match an exported member.

Solution: Use the .then((m) => m.ComponentName) pattern to select the named export:

// Bad — no named export selected
const MyForm = dynamic(() => import('@/components/helix-form'));
// Good
const MyForm = dynamic(
() => import('@/components/helix-form').then((m) => m.HelixForm)
);