Skip to main content
This guide walks you through creating custom components in EverShop, following the project’s conventions and best practices.

Component Structure

EverShop components follow a consistent structure:
// ProductCard.tsx
import React from 'react';

// Define TypeScript interface for props
export interface ProductCardProps {
  name: string;
  price: number;
  imageUrl: string;
  onAddToCart?: () => void;
}

// Export the component function
export function ProductCard({
  name,
  price,
  imageUrl,
  onAddToCart
}: ProductCardProps) {
  return (
    <div className="product-card border rounded-lg p-4">
      <img src={imageUrl} alt={name} className="w-full h-48 object-cover" />
      <h3 className="text-lg font-semibold mt-2">{name}</h3>
      <p className="text-xl font-bold">${price}</p>
      {onAddToCart && (
        <button
          onClick={onAddToCart}
          className="mt-4 w-full bg-primary text-white py-2 rounded"
        >
          Add to Cart
        </button>
      )}
    </div>
  );
}

Creating Components Step-by-Step

1

Choose the Right Location

Determine where your component belongs:Common Components (packages/evershop/src/components/common/):
  • Shared across admin and storefront
  • UI primitives (buttons, forms, modals)
  • Utility components (loaders, notifications)
Admin Components (packages/evershop/src/components/admin/):
  • Admin panel specific
  • Data tables, selectors, form controls
  • Admin layout components
Storefront Components (packages/evershop/src/components/frontStore/):
  • Customer-facing components
  • Product displays, cart, checkout
  • Storefront layout components
Example:
# For a reusable rating component
packages/evershop/src/components/common/Rating.tsx

# For an admin dashboard widget
packages/evershop/src/components/admin/DashboardWidget.tsx

# For a product review component
packages/evershop/src/components/frontStore/catalog/ProductReviews.tsx
2

Create the Component File

Create a TypeScript file with your component:
// packages/evershop/src/components/common/Rating.tsx
import React from 'react';

export interface RatingProps {
  value: number;        // Current rating (0-5)
  max?: number;         // Maximum rating (default: 5)
  readonly?: boolean;   // Whether user can change rating
  onChange?: (value: number) => void;
  size?: 'sm' | 'md' | 'lg';
  className?: string;
}

export function Rating({
  value,
  max = 5,
  readonly = true,
  onChange,
  size = 'md',
  className = ''
}: RatingProps) {
  const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);

  const sizeClasses = {
    sm: 'w-4 h-4',
    md: 'w-5 h-5',
    lg: 'w-6 h-6'
  };

  const handleClick = (rating: number) => {
    if (!readonly && onChange) {
      onChange(rating);
    }
  };

  return (
    <div className={`flex items-center gap-1 ${className}`}>
      {Array.from({ length: max }, (_, index) => {
        const starValue = index + 1;
        const isFilled = (hoveredValue ?? value) >= starValue;

        return (
          <button
            key={index}
            type="button"
            onClick={() => handleClick(starValue)}
            onMouseEnter={() => !readonly && setHoveredValue(starValue)}
            onMouseLeave={() => !readonly && setHoveredValue(null)}
            disabled={readonly}
            className={`${
              readonly ? 'cursor-default' : 'cursor-pointer hover:scale-110'
            } transition-transform`}
            aria-label={`Rate ${starValue} out of ${max}`}
          >
            <svg
              className={`${sizeClasses[size]} ${
                isFilled ? 'fill-amber-400 text-amber-400' : 'fill-none text-border'
              }`}
              xmlns="http://www.w3.org/2000/svg"
              viewBox="0 0 24 24"
              stroke="currentColor"
              strokeWidth="2"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
              />
            </svg>
          </button>
        );
      })}
    </div>
  );
}
3

Add Styles (if needed)

For complex styling, create a companion SCSS file:
// Rating.scss
.rating {
  display: flex;
  align-items: center;
  gap: 0.25rem;

  &__star {
    transition: transform 0.2s ease;

    &:hover:not(:disabled) {
      transform: scale(1.1);
    }

    &--filled {
      fill: var(--color-amber-400);
      color: var(--color-amber-400);
    }

    &--empty {
      fill: none;
      color: var(--color-border);
    }
  }
}
Import in your component:
import './Rating.scss';
4

Use Existing UI Components

Leverage EverShop’s built-in UI components:
// ProductReviewForm.tsx
import { Form } from '@components/common/form/Form.js';
import { InputField } from '@components/common/form/InputField.js';
import { TextareaField } from '@components/common/form/TextareaField.js';
import { Button } from '@components/common/ui/Button.js';
import { Rating } from '@components/common/Rating.js';
import { useState } from 'react';

export interface ProductReviewFormProps {
  productId: string;
  onSuccess?: () => void;
}

export function ProductReviewForm({
  productId,
  onSuccess
}: ProductReviewFormProps) {
  const [rating, setRating] = useState(5);

  return (
    <Form
      action="/api/reviews"
      method="POST"
      onSuccess={() => {
        onSuccess?.();
      }}
    >
      <input type="hidden" name="productId" value={productId} />
      <input type="hidden" name="rating" value={rating} />

      <div className="mb-4">
        <label className="block mb-2 font-medium">Your Rating</label>
        <Rating
          value={rating}
          readonly={false}
          onChange={setRating}
          size="lg"
        />
      </div>

      <InputField
        name="title"
        label="Review Title"
        required
        placeholder="Summarize your experience"
      />

      <TextareaField
        name="comment"
        label="Your Review"
        required
        rows={5}
        placeholder="Share your thoughts about this product"
      />

      <InputField
        name="name"
        label="Your Name"
        required
        placeholder="Enter your name"
      />

      <Button type="submit" variant="default" size="lg">
        Submit Review
      </Button>
    </Form>
  );
}
5

Use React Hooks

Leverage React hooks for state and side effects:
// ProductSearch.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { InputField } from '@components/common/form/InputField.js';
import { Spinner } from '@components/common/ui/Spinner.js';
import { debounce } from '@evershop/evershop/lib/util/debounce';

export interface ProductSearchProps {
  onSelect: (product: Product) => void;
  placeholder?: string;
}

export function ProductSearch({
  onSelect,
  placeholder = 'Search products...'
}: ProductSearchProps) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  // Debounced search function
  const searchProducts = useCallback(
    debounce(async (searchQuery: string) => {
      if (!searchQuery.trim()) {
        setResults([]);
        return;
      }

      setIsLoading(true);
      try {
        const response = await fetch(
          `/api/products/search?q=${encodeURIComponent(searchQuery)}`
        );
        const data = await response.json();
        setResults(data.products || []);
      } catch (error) {
        console.error('Search failed:', error);
        setResults([]);
      } finally {
        setIsLoading(false);
      }
    }, 300),
    []
  );

  // Trigger search when query changes
  useEffect(() => {
    searchProducts(query);
  }, [query, searchProducts]);

  return (
    <div className="relative">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder={placeholder}
        className="w-full px-4 py-2 border rounded-lg"
      />

      {isLoading && (
        <div className="absolute right-3 top-3">
          <Spinner width={20} height={20} />
        </div>
      )}

      {results.length > 0 && (
        <div className="absolute z-10 w-full mt-1 bg-white border rounded-lg shadow-lg max-h-64 overflow-y-auto">
          {results.map((product) => (
            <button
              key={product.productId}
              onClick={() => {
                onSelect(product);
                setQuery('');
                setResults([]);
              }}
              className="w-full px-4 py-2 text-left hover:bg-muted flex items-center gap-3"
            >
              <img
                src={product.image?.url}
                alt={product.name}
                className="w-10 h-10 object-cover rounded"
              />
              <div>
                <p className="font-medium">{product.name}</p>
                <p className="text-sm text-muted-foreground">
                  ${product.price}
                </p>
              </div>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}
6

Integrate with Area System

Make your component extensible using the Area system:
// ProductDetails.tsx
import Area from '@components/common/Area.js';
import { Image } from '@components/common/Image.js';

export interface ProductDetailsProps {
  product: Product;
}

export function ProductDetails({ product }: ProductDetailsprops) {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
      <div>
        <Image
          src={product.image.url}
          width={800}
          height={800}
          alt={product.name}
        />
        {/* Allow extensions to add content below image */}
        <Area id="productImageBelow" coreComponents={[]} />
      </div>

      <div>
        <h1 className="text-3xl font-bold">{product.name}</h1>
        
        {/* Allow extensions to add badges, ratings, etc. */}
        <Area id="productNameAfter" coreComponents={[]} />

        <p className="text-2xl font-bold my-4">${product.price}</p>

        <div className="prose">
          {product.description}
        </div>

        {/* Allow extensions to add custom fields */}
        <Area id="productDescriptionAfter" coreComponents={[]} />

        {/* Allow extensions to modify or add to actions */}
        <Area
          id="productActions"
          coreComponents={[
            {
              component: { default: AddToCartButton },
              props: { product },
              sortOrder: 10
            }
          ]}
        />
      </div>
    </div>
  );
}
7

Add Tests (Optional)

Create tests for your component:
// Rating.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Rating } from './Rating';

describe('Rating', () => {
  it('renders the correct number of stars', () => {
    render(<Rating value={3} max={5} />);
    const stars = screen.getAllByRole('button');
    expect(stars).toHaveLength(5);
  });

  it('calls onChange when star is clicked', () => {
    const handleChange = jest.fn();
    render(
      <Rating value={3} readonly={false} onChange={handleChange} />
    );
    
    const stars = screen.getAllByRole('button');
    fireEvent.click(stars[4]); // Click 5th star
    
    expect(handleChange).toHaveBeenCalledWith(5);
  });

  it('does not call onChange when readonly', () => {
    const handleChange = jest.fn();
    render(
      <Rating value={3} readonly={true} onChange={handleChange} />
    );
    
    const stars = screen.getAllByRole('button');
    fireEvent.click(stars[4]);
    
    expect(handleChange).not.toHaveBeenCalled();
  });
});
8

Export and Use Your Component

Export your component and use it throughout your application:
// In your page or other component
import { Rating } from '@components/common/Rating.js';
import { ProductReviewForm } from '@components/frontStore/catalog/ProductReviewForm.js';

export default function ProductPage({ product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      
      {/* Show average rating */}
      <Rating value={product.averageRating} readonly size="lg" />
      <span>({product.reviewCount} reviews)</span>

      {/* Review form */}
      <div className="mt-8">
        <h2>Write a Review</h2>
        <ProductReviewForm
          productId={product.productId}
          onSuccess={() => {
            window.location.reload();
          }}
        />
      </div>
    </div>
  );
}

Best Practices

TypeScript Interfaces

Always define prop interfaces:
// Good
export interface ComponentProps {
  title: string;
  count?: number;
  onAction?: () => void;
}

export function Component({ title, count = 0, onAction }: ComponentProps) {
  // ...
}

// Bad
export function Component(props: any) {
  // ...
}

Default Props

Use destructuring with defaults:
export function Button({
  variant = 'default',
  size = 'md',
  disabled = false,
  children
}: ButtonProps) {
  // ...
}

Component Naming

Use PascalCase and descriptive names:
// Good
ProductCard.tsx
AddToCartButton.tsx
UserProfileForm.tsx

// Bad
productcard.tsx
btn.tsx
form1.tsx

Import Paths

Use path aliases and include .js extension:
// Good
import { Button } from '@components/common/ui/Button.js';
import { useCartState } from '@components/frontStore/cart/CartContext.js';

// Bad
import { Button } from '../../../common/ui/Button';
import { useCartState } from '../../cart/CartContext.tsx';

Accessibility

Make components accessible:
<button
  onClick={handleClick}
  aria-label="Add to cart"
  aria-disabled={isDisabled}
  disabled={isDisabled}
>
  Add to Cart
</button>

<input
  type="text"
  id="email"
  aria-invalid={hasError ? 'true' : 'false'}
  aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && <span id="email-error">{errorMessage}</span>}

Error Handling

Handle errors gracefully:
export function ProductList({ products }: ProductListProps) {
  const [error, setError] = useState<string | null>(null);

  if (error) {
    return (
      <div className="text-destructive p-4 border border-destructive rounded">
        <p>Failed to load products: {error}</p>
        <button onClick={() => setError(null)}>Retry</button>
      </div>
    );
  }

  // ... rest of component
}

Performance Optimization

Use React.memo for expensive components:
import React from 'react';

interface ProductCardProps {
  product: Product;
  onAddToCart: (sku: string) => void;
}

export const ProductCard = React.memo(function ProductCard({
  product,
  onAddToCart
}: ProductCardProps) {
  return (
    <div className="product-card">
      {/* ... */}
    </div>
  );
});
Use useCallback for event handlers:
const handleAddToCart = useCallback(() => {
  dispatch(addToCart(product.sku));
}, [product.sku, dispatch]);

Common Patterns

Render Props Pattern

export function DataLoader<T>({
  url,
  children
}: {
  url: string;
  children: (data: T | null, loading: boolean, error: string | null) => ReactNode;
}) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [url]);

  return <>{children(data, loading, error)}</>;
}

// Usage
<DataLoader<Product[]> url="/api/products">
  {(products, loading, error) => (
    loading ? <Spinner /> :
    error ? <ErrorMessage error={error} /> :
    <ProductList products={products} />
  )}
</DataLoader>

Compound Components Pattern

export function Card({ children, className }: CardProps) {
  return <div className={`card ${className}`}>{children}</div>;
}

Card.Header = function CardHeader({ children }: { children: ReactNode }) {
  return <div className="card-header">{children}</div>;
};

Card.Body = function CardBody({ children }: { children: ReactNode }) {
  return <div className="card-body">{children}</div>;
};

Card.Footer = function CardFooter({ children }: { children: ReactNode }) {
  return <div className="card-footer">{children}</div>;
};

// Usage
<Card>
  <Card.Header>
    <h3>Product Title</h3>
  </Card.Header>
  <Card.Body>
    <p>Product description...</p>
  </Card.Body>
  <Card.Footer>
    <Button>Add to Cart</Button>
  </Card.Footer>
</Card>

Component Overview

Learn about the component system

Admin Components

Browse admin components

Storefront Components

Explore storefront components

Build docs developers (and LLMs) love