Single Directory Components landed as experimental in Drupal 10.1 and became stable in 10.3. The idea is unglamorous: put a component's Twig template, its CSS, its JavaScript and its schema in one folder, and let Drupal treat that folder as the component. No more hunting through a theme for the library definition that attaches the stylesheet for the card you are looking at.
That sounds like tidiness rather than capability, and for a five-page site it is. On a recent build it turned out to matter more than we expected, because the project had a lot of components, several rendering contexts, and content that had to behave differently depending on whether the person looking at it had paid.
The project
The client runs a membership organisation whose value is the information its members share with each other: guidance documents, briefings, research, and a long tail of practical material that had previously been circulated as email attachments. They wanted a platform where that material could be published, categorised, and most importantly, actually found.
Three requirements shaped the build:
- Anyone can search everything. Search results include paid content. Hiding it entirely would mean nobody knows the good material exists.
- Some content is gated. Non-members see a summary and a prompt to subscribe; members see the full document.
- Search has to understand intent. Members do not know the organisation's internal terminology, so exact keyword matching returns nothing useful.
Drupal was the obvious fit for the first two. The third is where the interesting work was.
Why SDC earned its place here
A component on this site can render in at least four contexts: in a search result list, in a related-content sidebar, as a teaser on a landing page, and as the full node. Each context wants the same visual identity and different amounts of content. Under the old approach that means one Twig template, a library entry somewhere in the theme's YAML, CSS in a directory organised by a convention only the original developer remembers, and preprocess functions that quietly assume one of the contexts.
With SDC, the component is a directory. Here is roughly what a gated content card looks like:
components/
content-card/
content-card.component.yml
content-card.twig
content-card.css
content-card.jsThe `.component.yml` file is the part that changes how you work. It declares the component's props as JSON schema, which means Drupal validates what you pass in:
name: Content card
status: stable
props:
type: object
required:
- title
- url
- access_state
properties:
title:
type: string
url:
type: string
summary:
type: string
access_state:
type: string
enum:
- open
- gated_preview
- unlocked
relevance:
type: number
description: Similarity score, only present in AI search results.
slots:
footer:
title: Footer
description: Subscription prompt or metadata, depending on access state.That `enum` on `access_state` is doing real work. On a site where the difference between `gated_preview` and `unlocked` is the difference between showing and not showing paid content, a typo in a template variable is not a cosmetic bug. Passing an unrecognised value now fails loudly in development rather than falling through a Twig conditional to whichever branch happens to be the default.
Rendering it is a single Twig call, from anywhere, a template, a preprocess function, a views field, a custom block:
{{ include('mytheme:content-card', {
title: node.label,
url: url,
summary: summary,
access_state: access_state,
}, with_context = false) }}`with_context = false` is worth being deliberate about. It means the component only receives what you explicitly pass, so it cannot quietly depend on a variable that happens to be in scope in one of the four contexts and absent in the others. That single flag removed a class of bug we have spent hours on in previous builds.
The practical wins
- CSS and JS attach themselves. Drupal builds the asset library from the directory. Nobody has to remember to add the component to `theme.libraries.yml`, and nothing loads on pages where the component is not rendered.
- Deleting a component actually deletes it. Removing the folder removes the template, the styles and the behaviour. Previously, orphaned CSS accumulated for years because nobody could prove what still used it.
- Props are documented where the component lives. A developer picking up the card six months later reads the schema, not the four call sites.
- Components show up in the UI. Because they are discoverable and typed, tools that consume component definitions can list them, which matters if you later move towards editorial layout building.
Handling the paywall without lying to search engines
The tempting shortcut with gated content is to render the whole document and hide the bulk of it with CSS. Do not do that. The text is in the response, so anyone can read it with developer tools open, and you are one careless cache configuration away from serving a full document to everybody.
We resolve access on the server and render a genuinely different payload. The component receives an `access_state` and a summary that is a real, separately stored field rather than a truncation of the body. What the visitor is not entitled to never reaches the browser.
The part people underestimate is caching. A paywalled page varies by the visitor's entitlement, so the render array has to say so:
$build['#cache']['contexts'][] = 'user.roles';
$build['#cache']['contexts'][] = 'user.permissions';
$build['#cache']['tags'][] = 'subscription:' . $subscription->id();Get the cache contexts wrong and the failure mode is the worst kind: it works in testing, and then one anonymous visitor is served a member's cached page. We treat this as a thing to test explicitly, not a thing to reason about carefully and hope.
For search engines, gated documents are marked up with `isAccessibleForFree: false` and a `hasPart` block identifying the paywalled section. That is Google's documented way of saying "this is behind a paywall" without it being treated as cloaking, and it lets the summary be indexed while the body is not.
Predictive search with OpenAI
Keyword search failed on this content in a specific, predictable way. A member searching "what do I do if a supplier goes bust" gets nothing, because the document that answers it is titled "Managing counterparty insolvency risk". They share no significant words. The information was there and unfindable, which was the problem the client came to us with in the first place.
So search runs on meaning rather than words. Each document is chunked and passed to OpenAI's embeddings API, which returns a vector, a numeric representation of what the text is about. Those vectors are stored alongside the content. A query is embedded the same way, and we retrieve the chunks whose vectors sit closest to it.
// Embed once on save, not on every search: it is a network call and it costs money.
$response = $this->openAi->embeddings([
'model' => 'text-embedding-3-small',
'input' => $chunk,
]);
$this->vectorStore->upsert([
'id' => $node->id() . ':' . $chunkIndex,
'embedding' => $response['data'][0]['embedding'],
'metadata' => [
'nid' => $node->id(),
'access' => $node->get('field_access_level')->value,
],
]);Two details from that snippet matter more than the API call itself.
First, embedding happens on save, in a queue. Embedding at search time would put a third-party API on the critical path of every query, which is both slow and an availability risk you do not control. Content changes far less often than it is searched.
Second, the access level is stored in the vector metadata. This is the paywall consideration reappearing in a new place: the vector store is a second copy of your content's access rules, and if it drifts from Drupal's, the drift is invisible. We store the access level with the vector and then re-check entitlement against Drupal before rendering. The vector store decides what is relevant; Drupal decides what the visitor may read. Never the other way round.
Predictive suggestions as the visitor types
Semantic search is good but it is not instant, and an embedding call per keystroke would be absurd. So the type-ahead layer is deliberately dumber: a fast prefix match against titles, taxonomy terms and previous successful queries, served from a lightweight endpoint and debounced in the browser. Semantic retrieval runs when the visitor commits to a search.
This is the pattern we would recommend to anyone adding AI search to an existing site. The impressive part and the responsive part are different systems, and trying to make one do both jobs produces something that is neither fast nor smart.
The result card is the same SDC component used everywhere else, with `relevance` populated and `access_state` set from the entitlement check. One component, one set of styles, whether it is showing an open document, a gated preview or an unlocked one.
Where SDC did not help
It is worth being straight about the limits, because SDC is currently written up with more enthusiasm than qualification.
- It is a rendering layer, not an architecture. The hard parts of this build were entitlement logic, cache contexts and keeping the vector store honest. SDC touched none of them.
- Schema validation is a development-time tool. It catches your mistakes while you are working. It is not a runtime security boundary, and access control must not depend on it.
- Retrofitting is real work. Converting an existing theme component by component is worthwhile, but it is a migration, not a refactor you slip into a sprint. We converted the components that were actively causing pain and left the rest.
- Slots need discipline. They make components flexible, and flexible components drift back towards the one-template-does-everything mess SDC is meant to prevent. We keep slots for genuinely variable regions, and use props everywhere else.
What we would do the same way again
Resolve access on the server and send only what the visitor is entitled to. Store a real summary field rather than truncating a body you should not have loaded. Declare cache contexts explicitly and test them as a security concern. Embed content on save, not on search. Let the vector store rank and let Drupal authorise. And keep the fast type-ahead separate from the clever retrieval.
SDC did not make any of those decisions for us. What it did was stop the component layer from being the thing that went wrong while we were concentrating on them, which on a build of this size was worth having.
