Creating a theme

A GrandNode storefront theme is a plugin in the Themes group. It does not fork the storefront: it overrides only the Razor views it ships and every other view falls through to the defaults in Grand.Web/Views. The shipped Theme.Modern and Theme.Nordic are complete references; this article explains the mechanism and the files you need.

The files

src/Plugins/Theme.Example/
  Theme.Example.csproj
  Manifest.cs               Group = "Themes", SystemName = "Theme.Example"
  ExampleThemePlugin.cs     BasePlugin
  ExampleThemeView.cs       IThemeView - the whole mechanism
  StartupApplication.cs     registers the IThemeView
  logo.jpg                  plugin list logo
  Content/                  CSS, JS, fonts, images, theme.jpg
  Views/Example/            the overridden views
    _ViewImports.cshtml
    _ViewStart.cshtml
    Shared/...
    Product/...

A theme without settings needs no install logic:

public class ExampleThemePlugin : BasePlugin, IPlugin;

And one registration:

public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
    services.AddScoped<IThemeView, ExampleThemeView>();
}

IThemeView and the fallback

public class ExampleThemeView : IThemeView
{
    public string AreaName => "";
    public string ThemeName => "Example";

    public ThemeInfo ThemeInfo => new("Example theme",
        "~/Plugins/Theme.Example/Content/theme.jpg", "A short description", false);

    public IEnumerable<string> GetViewLocations()
    {
        return new List<string> {
            "/Views/Example/{1}/{0}.cshtml",
            "/Views/Example/Shared/{0}.cshtml",
            "/Views/{1}/{0}.cshtml",
            "/Views/Shared/{0}.cshtml"
        };
    }
}
MemberWhat it does
AreaNameEmpty for storefront themes.
ThemeNameThe key stored in settings. It must equal the folder name under Views/, because the location strings contain it.
ThemeInfoTitle, preview image, preview text and whether the theme supports right-to-left languages. The admin offers only RTL-capable themes for RTL stores.
GetViewLocations()Ordered view locations; {0} is the view name, {1} the controller name. The first match wins.

The last two locations are the fallback. With them, a request for Product/ProductTemplate.Simple uses /Views/Example/Product/ProductTemplate.Simple.cshtml if the theme has it and the default view otherwise. Remove them and every page the theme has not copied fails with "view not found".

Overriding views

  1. Copy _ViewImports.cshtml from an existing theme into Views/Example/. A theme folder does not inherit Grand.Web's imports. Keep the line that removes the default input tag helper, otherwise every checkbox renders twice:
    @removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.InputTagHelper, Microsoft.AspNetCore.Mvc.TagHelpers
  2. Add _ViewStart.cshtml.
  3. Copy the views you want to change from src/Web/Grand.Web/Views into the same relative path under Views/Example/. Start with layouts and shared partials — that is where the look of a theme lives. Theme.Modern overrides about 110 of the 235 default views, most of them in Shared/.
  4. Keep the @model, route names and storefront data attributes (add to cart, wishlist, compare, quick view) of every view you copy, and keep its widget zones:
    @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_top" })
    Dropping a zone silently disables every widget plugin on that page.

A theme never changes view models, controllers or routes. If a theme needs data the storefront does not provide, the change belongs in Grand.Web.

Content assets

CSS, JavaScript, fonts and images go under Content/ and are referenced as ~/Plugins/Theme.Example/Content/.... Mark them to be copied in the project file, and do not load them from external CDNs:

<ItemGroup>
  <None Update="Content\**\*.*"><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></None>
  <None Update="logo.jpg"><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></None>
</ItemGroup>

A theme also references Grand.Web to compile against the storefront view models, with <Private>false</Private> and <ExcludeAssets>all</ExcludeAssets> so the host assembly is not copied. The rest of the project file follows Writing a plugin: Razor SDK, AddRazorSupportForMvc, StaticWebAssetsEnabled=false and an output path to Grand.Web/Plugins/Theme.Example for both Debug and Release.

Prefer CSS over copying a view. Every copied view has to be compared with its upstream version on each GrandNode upgrade; a stylesheet rule does not.

Activating the theme

  1. Build the theme and install it under Plugins → Local plugins (group Themes).
  2. Open Settings → General and choose the theme in Default store theme. The setting is store-scoped, so each store can use a different theme.
  3. Optionally enable Allow customers to select a theme. The customer's choice is kept in a cookie and wins over the default.

Razor views of a theme are compiled into its DLL: after editing a view, rebuild the theme and restart the site.

Tips and common mistakes

SymptomCause
Theme selected but pages show the default markupThemeName does not match the Views/ folder, or the IThemeView is not registered.
Some pages fail with "view not found"The fallback locations are missing from GetViewLocations().
Tag helpers are not resolved in theme viewsNo _ViewImports.cshtml in the theme folder.
Widgets disappear on one pageA widget zone was dropped from a copied view.
Theme CSS returns 404Content/** is not copied to the output.

Question not answered here? Ask the community on GitHub Discussions ↗