URL state
Making a search shareable and navigable through the address bar — with the helpers' short keys, and their rough edges.
A search that lives only in memory cannot be shared, bookmarked, or reached with the back button. Putting it in the URL fixes all three, and the library ships helpers for the encoding.
These helpers predate the current filter model
QueryParams.filters is typed LegacyFilter[], not Filter. So round-tripping through these helpers converts
your filters to the legacy shape, and you convert back with legacyFilterToFilter. For new applications,
serialising your own state is often cleaner — the last section shows both.
The short keys
The encoding uses one- or two-letter parameters, so a shared URL stays readable:
| Key | Holds |
|---|---|
q | The search text |
f | The filters, as encoded JSON |
p | The page |
s | The sort choice name |
t | The selected tab |
n | The query web service name |
b | The basket |
id | The opened document |
Prop
Type
A shareable search
Read the URL on load, write it on change
import {
createAtomicClient,
filter,
getQueryParamsFromUrl,
getUrlParamsFromQueryParams,
legacyFilterToFilter,
} from "@sinequa/atomic";
import type { Filter, Query, QueryParams } from "@sinequa/atomic";
const client = createAtomicClient({
app: "<your-app>",
backendUrl: "https://<your-sinequa-server>",
});
await client.initializeConfig();
await client.auth.login();
/** The URL is the source of truth: read it, search, render. */
export async function searchFromUrl() {
const params = getQueryParamsFromUrl(window.location.href) ?? { filters: [] };
const query: Query = {
name: params.name ?? "<your-query>",
text: params.text,
// Falsy on a missing parameter, and `page` is one-based — see the pitfalls.
page: params.page || 1,
pageSize: 10,
tab: params.tab,
sort: params.sort,
// The helpers hand back the legacy shape; convert it to the current model.
filters: fromLegacy(params.filters),
};
return client.api.query.search(query);
}
/** Push the state into the URL without reloading the page. */
export function writeUrl(params: QueryParams) {
const search = getUrlParamsFromQueryParams(params);
history.pushState(null, "", `${window.location.pathname}?${search}`);
}
function fromLegacy(legacy: QueryParams["filters"]): Filter | undefined {
return filter.and(...(legacy ?? []).map((one) => legacyFilterToFilter(one)));
}
// Back and forward must re-run the search, or the view and the URL disagree.
window.addEventListener("popstate", () => {
void searchFromUrl();
});import { Injectable, inject } from "@angular/core";
import { Router } from "@angular/router";
import { filter, legacyFilterToFilter } from "@sinequa/atomic";
import type { Filter, Query } from "@sinequa/atomic";
/**
* The router's query parameters are the source of truth. Angular already parses
* them, so the library's URL helpers are only needed for the encoded `f` value.
*/
@Injectable({ providedIn: "root" })
export class SearchUrlService {
private readonly router = inject(Router);
toQuery(params: Record<string, string | undefined>): Query {
return {
name: params["n"] ?? "<your-query>",
text: params["q"],
// `page` is one-based: a missing parameter must not become 0.
page: Number(params["p"]) || 1,
pageSize: 10,
tab: params["t"],
sort: params["s"],
filters: this.decodeFilters(params["f"]),
};
}
/** Replaces the URL without stacking a history entry per keystroke. */
write(params: Record<string, string | number | undefined>, replace = false) {
return this.router.navigate([], {
queryParams: params,
queryParamsHandling: "merge",
replaceUrl: replace,
});
}
private decodeFilters(encoded: string | undefined): Filter | undefined {
if (!encoded) return undefined;
try {
const legacy = JSON.parse(decodeURIComponent(encoded));
// The stored shape is the legacy one; convert to the current model.
return filter.and(...legacy.map((one: unknown) => legacyFilterToFilter(one as never)));
} catch {
// A hand-edited URL should degrade to "no filter", not crash the page.
return undefined;
}
}
}import { ChangeDetectionStrategy, Component, inject, resource } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { SINEQUA } from "./sinequa";
import { SearchUrlService } from "./search-url.service";
@Component({
selector: "app-results",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (results.value(); as result) {
<p>{{ result.rowCount }} document(s) — page {{ result.page }}</p>
<ol>
@for (record of result.records; track record.id) {
<li>{{ record.title }}</li>
}
</ol>
}
`,
})
export class ResultsComponent {
private readonly client = inject(SINEQUA);
private readonly urls = inject(SearchUrlService);
/** Query params drive the search, so back and forward work for free. */
private readonly params = toSignal(inject(ActivatedRoute).queryParams, {
initialValue: {} as Record<string, string>,
});
readonly results = resource({
params: () => this.urls.toQuery(this.params()),
loader: ({ params }) => this.client.api.query.search(params),
});
}import { filter, legacyFilterToFilter } from "@sinequa/atomic";
import type { Filter, Result } from "@sinequa/atomic";
import { useCallback, useEffect, useState } from "react";
import { ready, sinequa } from "./sinequa";
function decodeFilters(encoded: string | null): Filter | undefined {
if (!encoded) return undefined;
try {
const legacy = JSON.parse(decodeURIComponent(encoded));
// The stored shape is the legacy one; convert to the current model.
return filter.and(...legacy.map((one: unknown) => legacyFilterToFilter(one as never)));
} catch {
// A hand-edited URL should degrade to "no filter", not crash the page.
return undefined;
}
}
/** The URL is the source of truth, so sharing and the back button work. */
export function useUrlSearch() {
const [result, setResult] = useState<Result>();
const [href, setHref] = useState(() => window.location.href);
// Back and forward must re-run the search.
useEffect(() => {
const onPop = () => setHref(window.location.href);
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
useEffect(() => {
const params = new URL(href).searchParams;
let active = true;
ready
.then(() =>
sinequa.api.query.search({
name: params.get("n") ?? "<your-query>",
text: params.get("q") ?? undefined,
// `page` is one-based: a missing parameter must not become 0.
page: Number(params.get("p")) || 1,
pageSize: 10,
tab: params.get("t") ?? undefined,
sort: params.get("s") ?? undefined,
filters: decodeFilters(params.get("f")),
}),
)
.then((next) => active && setResult(next));
return () => {
active = false;
};
}, [href]);
const write = useCallback((params: Record<string, string>, replace = false) => {
const url = new URL(window.location.href);
for (const [key, value] of Object.entries(params)) {
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
}
// replaceState while typing, pushState on a deliberate action.
history[replace ? "replaceState" : "pushState"](null, "", url);
setHref(url.href);
}, []);
return { result, write };
}