diff --git a/README.md b/README.md
index 2b9c02d..ed9054d 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,11 @@ This is a monorepo containing multiple standalone projects. Each project lives i
```plaintext
code-samples/
+├── typesense-angular-search-bar/ # Angular + Typesense search implementation
├── typesense-astro-search/ # Astro + Typesense search implementation
├── typesense-gin-full-text-search/ # Go (Gin) + Typesense backend implementation
├── typesense-next-search-bar/ # Next.js + Typesense search implementation
+├── typesense-nuxt-search-bar/ # Nuxt.js + Typesense search implementation
├── typesense-qwik-js-search/ # Qwik + Typesense search implementation
├── typesense-react-native-search-bar/ # React Native + Typesense search implementation
├── typesense-solid-js-search/ # SolidJS + Typesense search implementation
@@ -22,9 +24,11 @@ code-samples/
| Project | Framework | Description |
| ---------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------- |
+| [typesense-angular-search-bar](./typesense-angular-search-bar) | Angular | A modern search bar with instant search capabilities |
| [typesense-astro-search](./typesense-astro-search) | Astro | A modern search bar with instant search capabilities |
| [typesense-gin-full-text-search](./typesense-gin-full-text-search) | Go (Gin) | Backend API with full-text search using Typesense |
| [typesense-next-search-bar](./typesense-next-search-bar) | Next.js | A modern search bar with instant search capabilities |
+| [typesense-nuxt-search-bar](./typesense-nuxt-search-bar) | Nuxt.js | A modern search bar with instant search capabilities |
| [typesense-qwik-js-search](./typesense-qwik-js-search) | Qwik | Resumable search bar with real-time search and modern UI |
| [typesense-react-native-search-bar](./typesense-react-native-search-bar) | React Native | A mobile search bar with instant search capabilities |
| [typesense-solid-js-search](./typesense-solid-js-search) | SolidJS | A modern search bar with instant search capabilities |
diff --git a/typesense-angular-search-bar/.editorconfig b/typesense-angular-search-bar/.editorconfig
new file mode 100644
index 0000000..f166060
--- /dev/null
+++ b/typesense-angular-search-bar/.editorconfig
@@ -0,0 +1,17 @@
+# Editor configuration, see https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.ts]
+quote_type = single
+ij_typescript_use_double_quotes = false
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
diff --git a/typesense-angular-search-bar/.gitignore b/typesense-angular-search-bar/.gitignore
new file mode 100644
index 0000000..baedb75
--- /dev/null
+++ b/typesense-angular-search-bar/.gitignore
@@ -0,0 +1,45 @@
+# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
+
+# Compiled output
+/dist
+/tmp
+/out-tsc
+/bazel-out
+
+# Node
+/node_modules
+npm-debug.log
+yarn-error.log
+
+# IDEs and editors
+.idea/
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# Visual Studio Code
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.history/*
+
+# Environment config (contains API keys)
+/src/environments/environment.ts
+
+# Miscellaneous
+/.angular/cache
+.sass-cache/
+/connect.lock
+/coverage
+/libpeerconnection.log
+testem.log
+/typings
+
+# System files
+.DS_Store
+Thumbs.db
diff --git a/typesense-angular-search-bar/README.md b/typesense-angular-search-bar/README.md
new file mode 100644
index 0000000..f91813a
--- /dev/null
+++ b/typesense-angular-search-bar/README.md
@@ -0,0 +1,95 @@
+# Angular Search Bar with Typesense
+
+A modern search bar application built with Angular and Typesense, featuring instant search capabilities.
+
+## Tech Stack
+
+- Angular 18 (LTS)
+- Typesense
+- typesense-instantsearch-adapter & instantsearch.js
+
+## Prerequisites
+
+- Node.js 18+ and npm 9+.
+- Docker (for running Typesense locally). Alternatively, you can use a Typesense Cloud cluster.
+- Basic knowledge of Angular.
+
+## Quick Start
+
+### 1. Clone the repository
+
+```bash
+git clone https://github.com/typesense/code-samples.git
+cd typesense-angular-search-bar
+```
+
+### 2. Install dependencies
+
+```bash
+npm install
+```
+
+### 3. Set up environment variables
+
+Copy the example environment file and fill in your Typesense connection details:
+
+```bash
+cp src/environments/environment.example.ts src/environments/environment.ts
+```
+
+Then edit `src/environments/environment.ts`:
+
+```typescript
+export const environment = {
+ typesense: {
+ apiKey: 'your-api-key',
+ host: 'localhost',
+ port: 8108,
+ protocol: 'http',
+ index: 'books',
+ },
+};
+```
+
+### 4. Project Structure
+
+```text
+src/app/
+├── components/
+│ ├── heading/ # App title and branding
+│ ├── search-bar/ # Search input
+│ ├── book-list/ # Results grid
+│ └── book-card/ # Individual book display
+├── lib/
+│ └── instantsearch-adapter.ts # Typesense adapter config
+├── services/
+│ └── search.service.ts # InstantSearch singleton
+├── types/
+│ └── book.ts # Book type definition
+├── app.component.* # Root component
+└── app.config.ts # Angular app configuration
+```
+
+### 5. Start the development server
+
+```bash
+npm start
+```
+
+Open [http://localhost:4200](http://localhost:4200) in your browser.
+
+### 6. Deployment
+
+Update `src/environments/environment.ts` to point to your Typesense Cloud cluster:
+
+```typescript
+export const environment = {
+ typesense: {
+ apiKey: 'your-search-only-api-key',
+ host: 'xxx.typesense.net',
+ port: 443,
+ protocol: 'https',
+ index: 'books',
+ },
+};
+```
diff --git a/typesense-angular-search-bar/angular.json b/typesense-angular-search-bar/angular.json
new file mode 100644
index 0000000..4499e9c
--- /dev/null
+++ b/typesense-angular-search-bar/angular.json
@@ -0,0 +1,121 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "typesense-angular-search-bar": {
+ "projectType": "application",
+ "schematics": {
+ "@schematics/angular:class": {
+ "skipTests": true
+ },
+ "@schematics/angular:component": {
+ "skipTests": true
+ },
+ "@schematics/angular:directive": {
+ "skipTests": true
+ },
+ "@schematics/angular:guard": {
+ "skipTests": true
+ },
+ "@schematics/angular:interceptor": {
+ "skipTests": true
+ },
+ "@schematics/angular:pipe": {
+ "skipTests": true
+ },
+ "@schematics/angular:resolver": {
+ "skipTests": true
+ },
+ "@schematics/angular:service": {
+ "skipTests": true
+ }
+ },
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:application",
+ "options": {
+ "outputPath": "dist/typesense-angular-search-bar",
+ "index": "src/index.html",
+ "browser": "src/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "tsconfig.app.json",
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "public"
+ }
+ ],
+ "styles": ["src/styles.css"],
+ "scripts": [],
+ "allowedCommonJsDependencies": [
+ "algoliasearch-helper",
+ "@algolia/events",
+ "typesense",
+ "qs"
+ ]
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kB",
+ "maximumError": "1MB"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "2kB",
+ "maximumError": "4kB"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular-devkit/build-angular:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "typesense-angular-search-bar:build:production"
+ },
+ "development": {
+ "buildTarget": "typesense-angular-search-bar:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "extract-i18n": {
+ "builder": "@angular-devkit/build-angular:extract-i18n"
+ },
+ "test": {
+ "builder": "@angular-devkit/build-angular:karma",
+ "options": {
+ "polyfills": ["zone.js", "zone.js/testing"],
+ "tsConfig": "tsconfig.spec.json",
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "public"
+ }
+ ],
+ "styles": ["src/styles.css"],
+ "scripts": []
+ }
+ }
+ }
+ }
+ },
+ "cli": {
+ "analytics": false
+ }
+}
diff --git a/typesense-angular-search-bar/package.json b/typesense-angular-search-bar/package.json
new file mode 100644
index 0000000..f53f88b
--- /dev/null
+++ b/typesense-angular-search-bar/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "typesense-angular-search-bar",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve",
+ "build": "ng build",
+ "watch": "ng build --watch --configuration development",
+ "test": "ng test"
+ },
+ "private": true,
+ "dependencies": {
+ "@angular/animations": "^18.2.0",
+ "@angular/common": "^18.2.0",
+ "@angular/compiler": "^18.2.0",
+ "@angular/core": "^18.2.0",
+ "@angular/forms": "^18.2.0",
+ "@angular/platform-browser": "^18.2.0",
+ "@angular/platform-browser-dynamic": "^18.2.0",
+ "@angular/router": "^18.2.0",
+ "algoliasearch": "^5.50.1",
+ "instantsearch.js": "^4.93.0",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.3.0",
+ "typesense": "^3.0.5",
+ "typesense-instantsearch-adapter": "^3.0.2",
+ "zone.js": "~0.14.10"
+ },
+ "devDependencies": {
+ "@angular-devkit/build-angular": "^18.2.21",
+ "@angular/cli": "^18.2.21",
+ "@angular/compiler-cli": "^18.2.0",
+ "@types/jasmine": "~5.1.0",
+ "jasmine-core": "~5.2.0",
+ "karma": "~6.4.0",
+ "karma-chrome-launcher": "~3.2.0",
+ "karma-coverage": "~2.2.0",
+ "karma-jasmine": "~5.1.0",
+ "karma-jasmine-html-reporter": "~2.1.0",
+ "typescript": "~5.5.2"
+ }
+}
diff --git a/typesense-angular-search-bar/public/favicon.ico b/typesense-angular-search-bar/public/favicon.ico
new file mode 100644
index 0000000..57614f9
Binary files /dev/null and b/typesense-angular-search-bar/public/favicon.ico differ
diff --git a/typesense-angular-search-bar/public/favicon.png b/typesense-angular-search-bar/public/favicon.png
new file mode 100644
index 0000000..f42ec19
Binary files /dev/null and b/typesense-angular-search-bar/public/favicon.png differ
diff --git a/typesense-angular-search-bar/src/app/app.component.css b/typesense-angular-search-bar/src/app/app.component.css
new file mode 100644
index 0000000..2b4eb28
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/app.component.css
@@ -0,0 +1,11 @@
+.app-container {
+ min-height: 100vh;
+ background-color: #f9fafb;
+ padding: 2rem 1rem;
+}
+
+ais-instantsearch {
+ display: block;
+ max-width: 80rem;
+ margin: 0 auto;
+}
diff --git a/typesense-angular-search-bar/src/app/app.component.html b/typesense-angular-search-bar/src/app/app.component.html
new file mode 100644
index 0000000..bfadbc8
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/app.component.html
@@ -0,0 +1,5 @@
+
diff --git a/typesense-angular-search-bar/src/app/app.component.ts b/typesense-angular-search-bar/src/app/app.component.ts
new file mode 100644
index 0000000..3403d89
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/app.component.ts
@@ -0,0 +1,13 @@
+import { Component } from '@angular/core';
+import { HeadingComponent } from './components/heading/heading.component';
+import { SearchBarComponent } from './components/search-bar/search-bar.component';
+import { BookListComponent } from './components/book-list/book-list.component';
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [HeadingComponent, SearchBarComponent, BookListComponent],
+ templateUrl: './app.component.html',
+ styleUrl: './app.component.css',
+})
+export class AppComponent {}
diff --git a/typesense-angular-search-bar/src/app/app.config.ts b/typesense-angular-search-bar/src/app/app.config.ts
new file mode 100644
index 0000000..034603c
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/app.config.ts
@@ -0,0 +1,5 @@
+import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
+
+export const appConfig: ApplicationConfig = {
+ providers: [provideZoneChangeDetection({ eventCoalescing: true })],
+};
diff --git a/typesense-angular-search-bar/src/app/components/book-card/book-card.component.css b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.css
new file mode 100644
index 0000000..6fe4601
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.css
@@ -0,0 +1,90 @@
+.book-card {
+ display: flex;
+ gap: 1.5rem;
+ padding: 1.5rem;
+ background-color: white;
+ border-radius: 0.5rem;
+ box-shadow:
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
+ 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ transition: box-shadow 200ms ease-in-out;
+}
+
+.book-card:hover {
+ box-shadow:
+ 0 10px 15px -3px rgba(0, 0, 0, 0.1),
+ 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+}
+
+.book-image-container {
+ flex-shrink: 0;
+ width: 8rem;
+ height: 12rem;
+ background-color: #f3f4f6;
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+
+.book-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.no-image {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #9ca3af;
+}
+
+.book-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.book-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #111827;
+ margin: 0 0 0.5rem 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.book-author {
+ color: #4b5563;
+ margin: 0 0 0.25rem 0;
+ font-size: 0.875rem;
+}
+
+.book-year {
+ color: #6b7280;
+ font-size: 0.75rem;
+ margin: 0 0 0.5rem 0;
+}
+
+.rating-container {
+ margin-top: auto;
+ padding-top: 0.5rem;
+ display: flex;
+ align-items: center;
+}
+
+.star-rating {
+ color: #f59e0b;
+ font-size: 1.125rem;
+ line-height: 1;
+}
+
+.rating-text {
+ margin-left: 0.5rem;
+ font-size: 0.75rem;
+ color: #4b5563;
+}
diff --git a/typesense-angular-search-bar/src/app/components/book-card/book-card.component.html b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.html
new file mode 100644
index 0000000..b26906e
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.html
@@ -0,0 +1,30 @@
+
+
+ @if (book.image_url) {
+
![]()
+ } @else {
+
No Image
+ }
+
+
+
+
{{ book.title }}
+
By: {{ authorList }}
+
+ @if (book.publication_year) {
+
Published: {{ book.publication_year }}
+ }
+
+
+
{{ stars }}
+
+ {{ formattedRating }} ({{ formattedRatingsCount }} ratings)
+
+
+
+
diff --git a/typesense-angular-search-bar/src/app/components/book-card/book-card.component.ts b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.ts
new file mode 100644
index 0000000..6f237f8
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/book-card/book-card.component.ts
@@ -0,0 +1,32 @@
+import { Component, Input } from '@angular/core';
+import { Book } from '../../types/book';
+
+@Component({
+ selector: 'app-book-card',
+ standalone: true,
+ templateUrl: './book-card.component.html',
+ styleUrl: './book-card.component.css',
+})
+export class BookCardComponent {
+ @Input({ required: true }) book!: Book;
+
+ get stars(): string {
+ return '\u2605'.repeat(Math.round(this.book.average_rating || 0));
+ }
+
+ get formattedRating(): string {
+ return (this.book.average_rating || 0).toFixed(1);
+ }
+
+ get formattedRatingsCount(): string {
+ return (this.book.ratings_count || 0).toLocaleString();
+ }
+
+ get authorList(): string {
+ return this.book.authors?.join(', ') ?? '';
+ }
+
+ onImageError(event: Event): void {
+ (event.target as HTMLImageElement).style.display = 'none';
+ }
+}
diff --git a/typesense-angular-search-bar/src/app/components/book-list/book-list.component.css b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.css
new file mode 100644
index 0000000..583ecc6
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.css
@@ -0,0 +1,25 @@
+.book-list {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1.5rem;
+ padding: 1.5rem 1.5rem;
+ margin: 1.5rem 1.5rem;
+}
+
+@media (min-width: 768px) {
+ .book-list {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (min-width: 1024px) {
+ .book-list {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+.empty-state {
+ text-align: center;
+ padding: 3rem 0;
+ color: #6b7280;
+}
diff --git a/typesense-angular-search-bar/src/app/components/book-list/book-list.component.html b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.html
new file mode 100644
index 0000000..ca4be88
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.html
@@ -0,0 +1,9 @@
+@if (hasSearched && hits.length === 0) {
+ No books found. Try a different search term.
+} @else {
+
+ @for (hit of hits; track hit.id) {
+
+ }
+
+}
diff --git a/typesense-angular-search-bar/src/app/components/book-list/book-list.component.ts b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.ts
new file mode 100644
index 0000000..6cd9e28
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/book-list/book-list.component.ts
@@ -0,0 +1,40 @@
+import { Component, NgZone, OnInit, OnDestroy } from '@angular/core';
+import connectHits from 'instantsearch.js/es/connectors/hits/connectHits';
+import { SearchService } from '../../services/search.service';
+import { BookCardComponent } from '../book-card/book-card.component';
+import { Book } from '../../types/book';
+
+@Component({
+ selector: 'app-book-list',
+ standalone: true,
+ imports: [BookCardComponent],
+ templateUrl: './book-list.component.html',
+ styleUrl: './book-list.component.css',
+})
+export class BookListComponent implements OnInit, OnDestroy {
+ hits: Book[] = [];
+ hasSearched = false;
+ private widget: ReturnType> | null = null;
+
+ constructor(
+ private searchService: SearchService,
+ private ngZone: NgZone,
+ ) {}
+
+ ngOnInit(): void {
+ const hitsConnector = connectHits((renderOptions) => {
+ this.ngZone.run(() => {
+ this.hits = renderOptions.hits as unknown as Book[];
+ this.hasSearched = true;
+ });
+ });
+ this.widget = hitsConnector({});
+ this.searchService.searchInstance.addWidgets([this.widget]);
+ }
+
+ ngOnDestroy(): void {
+ if (this.widget) {
+ this.searchService.searchInstance.removeWidgets([this.widget]);
+ }
+ }
+}
diff --git a/typesense-angular-search-bar/src/app/components/heading/heading.component.css b/typesense-angular-search-bar/src/app/components/heading/heading.component.css
new file mode 100644
index 0000000..13ca750
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/heading/heading.component.css
@@ -0,0 +1,95 @@
+.heading {
+ width: fit-content;
+ text-align: right;
+ position: relative;
+ margin: 6rem auto 2rem;
+ isolation: isolate;
+}
+
+.heading::after {
+ content: "";
+ background: conic-gradient(
+ from 180deg at 50% 50%,
+ #ed0e73 0deg,
+ hsla(210, 100%, 52%, 0.2) 55deg,
+ #54d6ff33 140deg,
+ #dd003059 160deg,
+ transparent 360deg
+ );
+ width: 320px;
+ height: 180px;
+ z-index: -1;
+ left: 70%;
+ position: absolute;
+ filter: blur(45px);
+ top: -50%;
+ animation: move 40s ease-out infinite;
+}
+
+.heading h1 {
+ font-size: clamp(1.5rem, 4vw, 2.5rem);
+ font-weight: 700;
+ color: #1a1a1a;
+ margin: 0 0 0.5rem 0;
+}
+
+.subtitle {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.25rem;
+ font-size: clamp(0.875rem, 2vw, 1rem);
+}
+
+.typesense {
+ color: #ed0e73;
+ font-size: clamp(0.875rem, 2vw, 1rem);
+ text-decoration: none;
+ font-family: monospace;
+}
+
+.typesense:hover {
+ text-decoration: underline;
+}
+
+.angular-logo {
+ width: clamp(20px, 3vw, 28px);
+ position: relative;
+ margin-left: 0.25rem;
+ vertical-align: middle;
+}
+
+.github-link {
+ position: fixed;
+ top: 2vmax;
+ right: 2vmax;
+ color: #333;
+ transition: color 0.2s ease;
+}
+
+.github-link:hover {
+ color: #000;
+}
+
+.github-link svg {
+ width: 28px;
+ height: 28px;
+}
+
+@keyframes move {
+ 0% {
+ transform: translate(0%) scale(1);
+ }
+ 45% {
+ transform: translate(-40%, 20%) scale(1.2);
+ }
+ 75% {
+ transform: translate(5%, 40%) scale(1.3);
+ }
+ 90% {
+ transform: translate(-5%, 5%) scale(0.9);
+ }
+ 100% {
+ transform: translate(0%) scale(1);
+ }
+}
diff --git a/typesense-angular-search-bar/src/app/components/heading/heading.component.html b/typesense-angular-search-bar/src/app/components/heading/heading.component.html
new file mode 100644
index 0000000..1cf4cb2
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/heading/heading.component.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
diff --git a/typesense-angular-search-bar/src/app/components/heading/heading.component.ts b/typesense-angular-search-bar/src/app/components/heading/heading.component.ts
new file mode 100644
index 0000000..1f61024
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/heading/heading.component.ts
@@ -0,0 +1,10 @@
+import { Component, ViewEncapsulation } from '@angular/core';
+
+@Component({
+ selector: 'app-heading',
+ standalone: true,
+ templateUrl: './heading.component.html',
+ styleUrl: './heading.component.css',
+ encapsulation: ViewEncapsulation.None,
+})
+export class HeadingComponent {}
diff --git a/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.css b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.css
new file mode 100644
index 0000000..1b78733
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.css
@@ -0,0 +1,66 @@
+.search-container {
+ max-width: 48rem;
+ margin: 0 auto 2rem;
+}
+
+.search-form {
+ position: relative;
+}
+
+.search-input {
+ width: 100%;
+ padding: 1rem 3rem;
+ border-radius: 0.5rem;
+ border: 2px solid #e5e7eb;
+ font-size: 1rem;
+ box-sizing: border-box;
+}
+
+.search-input::-webkit-search-cancel-button {
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
+}
+
+.search-button,
+.reset-button {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #9ca3af;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.search-button {
+ left: 0.75rem;
+}
+
+.reset-button {
+ right: 0.75rem;
+}
+
+.reset-button:hover {
+ color: #4b5563;
+}
+
+.search-icon,
+.close-icon {
+ width: 1.25rem;
+ height: 1.25rem;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ fill: none;
+}
diff --git a/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.html b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.html
new file mode 100644
index 0000000..d14ce18
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.html
@@ -0,0 +1,47 @@
+
diff --git a/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.ts b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.ts
new file mode 100644
index 0000000..48ed987
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/components/search-bar/search-bar.component.ts
@@ -0,0 +1,46 @@
+import { Component, NgZone, OnInit, OnDestroy } from '@angular/core';
+import connectSearchBox from 'instantsearch.js/es/connectors/search-box/connectSearchBox';
+import { SearchService } from '../../services/search.service';
+
+@Component({
+ selector: 'app-search-bar',
+ standalone: true,
+ templateUrl: './search-bar.component.html',
+ styleUrl: './search-bar.component.css',
+})
+export class SearchBarComponent implements OnInit, OnDestroy {
+ query = '';
+ private refineFn: (value: string) => void = () => {};
+ private widget: ReturnType> | null = null;
+
+ constructor(
+ private searchService: SearchService,
+ private ngZone: NgZone,
+ ) {}
+
+ ngOnInit(): void {
+ const searchBoxConnector = connectSearchBox((renderOptions) => {
+ this.ngZone.run(() => {
+ this.query = renderOptions.query;
+ this.refineFn = renderOptions.refine;
+ });
+ });
+ this.widget = searchBoxConnector({});
+ this.searchService.searchInstance.addWidgets([this.widget]);
+ }
+
+ onSearch(event: Event): void {
+ const value = (event.target as HTMLInputElement).value;
+ this.refineFn(value);
+ }
+
+ onReset(): void {
+ this.refineFn('');
+ }
+
+ ngOnDestroy(): void {
+ if (this.widget) {
+ this.searchService.searchInstance.removeWidgets([this.widget]);
+ }
+ }
+}
diff --git a/typesense-angular-search-bar/src/app/lib/instantsearch-adapter.ts b/typesense-angular-search-bar/src/app/lib/instantsearch-adapter.ts
new file mode 100644
index 0000000..de687fa
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/lib/instantsearch-adapter.ts
@@ -0,0 +1,21 @@
+import TypesenseInstantsearchAdapter from 'typesense-instantsearch-adapter';
+import { environment } from '../../environments/environment';
+
+export const typesenseInstantSearchAdapter = new TypesenseInstantsearchAdapter({
+ server: {
+ apiKey: environment.typesense.apiKey,
+ nodes: [
+ {
+ host: environment.typesense.host,
+ port: environment.typesense.port,
+ protocol: environment.typesense.protocol,
+ },
+ ],
+ },
+ additionalSearchParameters: {
+ query_by: 'title,authors',
+ query_by_weights: '4,2',
+ num_typos: 1,
+ sort_by: 'ratings_count:desc',
+ },
+});
diff --git a/typesense-angular-search-bar/src/app/services/search.service.ts b/typesense-angular-search-bar/src/app/services/search.service.ts
new file mode 100644
index 0000000..13b8704
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/services/search.service.ts
@@ -0,0 +1,20 @@
+import { Injectable, OnDestroy } from '@angular/core';
+import instantsearch from 'instantsearch.js';
+import { typesenseInstantSearchAdapter } from '../lib/instantsearch-adapter';
+import { environment } from '../../environments/environment';
+
+@Injectable({ providedIn: 'root' })
+export class SearchService implements OnDestroy {
+ readonly searchInstance = instantsearch({
+ indexName: environment.typesense.index,
+ searchClient: typesenseInstantSearchAdapter.searchClient,
+ });
+
+ constructor() {
+ this.searchInstance.start();
+ }
+
+ ngOnDestroy(): void {
+ this.searchInstance.dispose();
+ }
+}
diff --git a/typesense-angular-search-bar/src/app/types/book.ts b/typesense-angular-search-bar/src/app/types/book.ts
new file mode 100644
index 0000000..4e53931
--- /dev/null
+++ b/typesense-angular-search-bar/src/app/types/book.ts
@@ -0,0 +1,9 @@
+export interface Book {
+ id: string;
+ title: string;
+ authors: string[];
+ publication_year: number;
+ average_rating: number;
+ image_url: string;
+ ratings_count: number;
+}
diff --git a/typesense-angular-search-bar/src/environments/environment.example.ts b/typesense-angular-search-bar/src/environments/environment.example.ts
new file mode 100644
index 0000000..2f6b1d2
--- /dev/null
+++ b/typesense-angular-search-bar/src/environments/environment.example.ts
@@ -0,0 +1,9 @@
+export const environment = {
+ typesense: {
+ apiKey: 'your-api-key-here',
+ host: 'localhost',
+ port: 8108,
+ protocol: 'http',
+ index: 'books',
+ },
+};
diff --git a/typesense-angular-search-bar/src/index.html b/typesense-angular-search-bar/src/index.html
new file mode 100644
index 0000000..4b49290
--- /dev/null
+++ b/typesense-angular-search-bar/src/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Angular Search Bar
+
+
+
+
+
+
+
+
+
diff --git a/typesense-angular-search-bar/src/main.ts b/typesense-angular-search-bar/src/main.ts
new file mode 100644
index 0000000..8882c45
--- /dev/null
+++ b/typesense-angular-search-bar/src/main.ts
@@ -0,0 +1,7 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { appConfig } from './app/app.config';
+import { AppComponent } from './app/app.component';
+
+bootstrapApplication(AppComponent, appConfig).catch((err) =>
+ console.error(err),
+);
diff --git a/typesense-angular-search-bar/src/styles.css b/typesense-angular-search-bar/src/styles.css
new file mode 100644
index 0000000..fea287a
--- /dev/null
+++ b/typesense-angular-search-bar/src/styles.css
@@ -0,0 +1,14 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
+ Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
diff --git a/typesense-angular-search-bar/tsconfig.app.json b/typesense-angular-search-bar/tsconfig.app.json
new file mode 100644
index 0000000..3775b37
--- /dev/null
+++ b/typesense-angular-search-bar/tsconfig.app.json
@@ -0,0 +1,15 @@
+/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
+/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": []
+ },
+ "files": [
+ "src/main.ts"
+ ],
+ "include": [
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/typesense-angular-search-bar/tsconfig.json b/typesense-angular-search-bar/tsconfig.json
new file mode 100644
index 0000000..a8bb65b
--- /dev/null
+++ b/typesense-angular-search-bar/tsconfig.json
@@ -0,0 +1,33 @@
+/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
+/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "sourceMap": true,
+ "declaration": false,
+ "experimentalDecorators": true,
+ "moduleResolution": "bundler",
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "lib": [
+ "ES2022",
+ "dom"
+ ]
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/typesense-angular-search-bar/tsconfig.spec.json b/typesense-angular-search-bar/tsconfig.spec.json
new file mode 100644
index 0000000..5fb748d
--- /dev/null
+++ b/typesense-angular-search-bar/tsconfig.spec.json
@@ -0,0 +1,15 @@
+/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
+/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/spec",
+ "types": [
+ "jasmine"
+ ]
+ },
+ "include": [
+ "src/**/*.spec.ts",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/typesense-nuxt-search-bar/.env.example b/typesense-nuxt-search-bar/.env.example
new file mode 100644
index 0000000..8a12cc6
--- /dev/null
+++ b/typesense-nuxt-search-bar/.env.example
@@ -0,0 +1,5 @@
+NUXT_PUBLIC_TYPESENSE_API_KEY=xyz
+NUXT_PUBLIC_TYPESENSE_HOST=localhost
+NUXT_PUBLIC_TYPESENSE_PORT=8108
+NUXT_PUBLIC_TYPESENSE_PROTOCOL=http
+NUXT_PUBLIC_TYPESENSE_INDEX=books
diff --git a/typesense-nuxt-search-bar/.gitignore b/typesense-nuxt-search-bar/.gitignore
new file mode 100644
index 0000000..4a7f73a
--- /dev/null
+++ b/typesense-nuxt-search-bar/.gitignore
@@ -0,0 +1,24 @@
+# Nuxt dev/build outputs
+.output
+.data
+.nuxt
+.nitro
+.cache
+dist
+
+# Node dependencies
+node_modules
+
+# Logs
+logs
+*.log
+
+# Misc
+.DS_Store
+.fleet
+.idea
+
+# Local env files
+.env
+.env.*
+!.env.example
diff --git a/typesense-nuxt-search-bar/README.md b/typesense-nuxt-search-bar/README.md
new file mode 100644
index 0000000..28572f6
--- /dev/null
+++ b/typesense-nuxt-search-bar/README.md
@@ -0,0 +1,115 @@
+# Nuxt.js Search Bar with Typesense
+
+A modern search bar application built with Nuxt.js and Typesense, featuring instant search capabilities.
+
+## Tech Stack
+
+- Nuxt.js
+- Vue 3
+- Typesense
+- typesense-instantsearch-adapter & vue-instantsearch
+- Tailwind CSS
+
+## Prerequisites
+
+- Node.js 18+ and npm 9+.
+- Docker (for running Typesense locally). Alternatively, you can use a Typesense Cloud cluster.
+- Basic knowledge of Vue and Nuxt.js.
+
+## Quick Start
+
+### 1. Clone the repository
+
+```bash
+git clone https://github.com/typesense/code-samples.git
+cd code-samples/typesense-nuxt-search-bar
+```
+
+### 2. Install dependencies
+
+```bash
+npm install
+```
+
+### 3. Set up Typesense and import data
+
+#### Start Typesense Server (Local Development)
+
+```bash
+docker run -d -p 8108:8108 \
+ -v/tmp/typesense-data:/data typesense/typesense:27.1 \
+ --data-dir /data --api-key=xyz --enable-cors
+```
+
+#### Create Collection and Import Data
+
+The application expects a `books` collection with the following schema:
+
+```json
+{
+ "name": "books",
+ "fields": [
+ {"name": "title", "type": "string"},
+ {"name": "authors", "type": "string[]"},
+ {"name": "publication_year", "type": "int32"},
+ {"name": "average_rating", "type": "float"},
+ {"name": "ratings_count", "type": "int32"},
+ {"name": "image_url", "type": "string", "optional": true}
+ ]
+}
+```
+
+### 4. Set up environment variables
+
+Create a `.env` file in the project root (copy from `.env.example`):
+
+```bash
+cp .env.example .env
+```
+
+Update the values in `.env`:
+
+```env
+NUXT_PUBLIC_TYPESENSE_API_KEY=xyz
+NUXT_PUBLIC_TYPESENSE_HOST=localhost
+NUXT_PUBLIC_TYPESENSE_PORT=8108
+NUXT_PUBLIC_TYPESENSE_PROTOCOL=http
+NUXT_PUBLIC_TYPESENSE_INDEX=books
+```
+
+### 5. Project Structure
+
+```text
+├── app
+│ └── app.vue
+├── components
+│ ├── BookCard.vue
+│ ├── BookList.vue
+│ ├── Heading.vue
+│ └── SearchBar.vue
+├── types
+│ └── Book.ts
+├── utils
+│ └── instantSearchAdapter.ts
+└── nuxt.config.ts
+```
+
+### 5. Start the development server
+
+```bash
+npm run dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) in your browser.
+
+### 6. Deployment
+
+Set env variables to point the app to the Typesense Cluster:
+
+```env
+NUXT_PUBLIC_TYPESENSE_API_KEY=xxx
+NUXT_PUBLIC_TYPESENSE_HOST=xxx.typesense.net
+NUXT_PUBLIC_TYPESENSE_PORT=443
+NUXT_PUBLIC_TYPESENSE_PROTOCOL=https
+NUXT_PUBLIC_TYPESENSE_INDEX=books
+```
diff --git a/typesense-nuxt-search-bar/app/app.vue b/typesense-nuxt-search-bar/app/app.vue
new file mode 100644
index 0000000..bfd3f0f
--- /dev/null
+++ b/typesense-nuxt-search-bar/app/app.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
diff --git a/typesense-nuxt-search-bar/components/BookCard.vue b/typesense-nuxt-search-bar/components/BookCard.vue
new file mode 100644
index 0000000..6be40d7
--- /dev/null
+++ b/typesense-nuxt-search-bar/components/BookCard.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
![]()
+
No Image
+
+
+
{{ book.title }}
+
By: {{ book.authors.join(", ") }}
+
Published: {{ book.publication_year }}
+
+
+ {{ "★".repeat(Math.round(book.average_rating)) }}
+
+
+ {{ book.average_rating.toFixed(1) }} ({{
+ book.ratings_count.toLocaleString()
+ }}
+ ratings)
+
+
+
+
+
+
+
diff --git a/typesense-nuxt-search-bar/components/BookList.vue b/typesense-nuxt-search-bar/components/BookList.vue
new file mode 100644
index 0000000..fbb17c3
--- /dev/null
+++ b/typesense-nuxt-search-bar/components/BookList.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+ No books found. Try a different search term.
+
+
+
+
+
+
+
+
+
diff --git a/typesense-nuxt-search-bar/components/Heading.vue b/typesense-nuxt-search-bar/components/Heading.vue
new file mode 100644
index 0000000..622403c
--- /dev/null
+++ b/typesense-nuxt-search-bar/components/Heading.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
diff --git a/typesense-nuxt-search-bar/components/SearchBar.vue b/typesense-nuxt-search-bar/components/SearchBar.vue
new file mode 100644
index 0000000..2ca65cf
--- /dev/null
+++ b/typesense-nuxt-search-bar/components/SearchBar.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/typesense-nuxt-search-bar/nuxt.config.ts b/typesense-nuxt-search-bar/nuxt.config.ts
new file mode 100644
index 0000000..b2c7cae
--- /dev/null
+++ b/typesense-nuxt-search-bar/nuxt.config.ts
@@ -0,0 +1,16 @@
+// https://nuxt.com/docs/api/configuration/nuxt-config
+export default defineNuxtConfig({
+ compatibilityDate: "2024-07-15",
+ devtools: { enabled: true },
+ runtimeConfig: {
+ public: {
+ typesense: {
+ apiKey: process.env.NUXT_PUBLIC_TYPESENSE_API_KEY || "xyz",
+ host: process.env.NUXT_PUBLIC_TYPESENSE_HOST || "localhost",
+ port: parseInt(process.env.NUXT_PUBLIC_TYPESENSE_PORT || "8108", 10),
+ protocol: process.env.NUXT_PUBLIC_TYPESENSE_PROTOCOL || "http",
+ index: process.env.NUXT_PUBLIC_TYPESENSE_INDEX || "books",
+ },
+ },
+ },
+});
diff --git a/typesense-nuxt-search-bar/package.json b/typesense-nuxt-search-bar/package.json
new file mode 100644
index 0000000..0ff1461
--- /dev/null
+++ b/typesense-nuxt-search-bar/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "typesense-nuxt-search-bar",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "build": "nuxt build",
+ "dev": "nuxt dev",
+ "generate": "nuxt generate",
+ "preview": "nuxt preview",
+ "postinstall": "nuxt prepare"
+ },
+ "dependencies": {
+ "nuxt": "^4.4.2",
+ "vue": "^3.5.30",
+ "vue-router": "^5.0.3",
+ "vue-instantsearch": "^4.20.4",
+ "typesense": "^2.1.0",
+ "typesense-instantsearch-adapter": "^2.9.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20"
+ }
+}
diff --git a/typesense-nuxt-search-bar/public/favicon.ico b/typesense-nuxt-search-bar/public/favicon.ico
new file mode 100644
index 0000000..18993ad
Binary files /dev/null and b/typesense-nuxt-search-bar/public/favicon.ico differ
diff --git a/typesense-nuxt-search-bar/public/favicon.png b/typesense-nuxt-search-bar/public/favicon.png
new file mode 100644
index 0000000..f42ec19
Binary files /dev/null and b/typesense-nuxt-search-bar/public/favicon.png differ
diff --git a/typesense-nuxt-search-bar/public/robots.txt b/typesense-nuxt-search-bar/public/robots.txt
new file mode 100644
index 0000000..0ad279c
--- /dev/null
+++ b/typesense-nuxt-search-bar/public/robots.txt
@@ -0,0 +1,2 @@
+User-Agent: *
+Disallow:
diff --git a/typesense-nuxt-search-bar/tsconfig.json b/typesense-nuxt-search-bar/tsconfig.json
new file mode 100644
index 0000000..307b213
--- /dev/null
+++ b/typesense-nuxt-search-bar/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ // https://nuxt.com/docs/guide/concepts/typescript
+ "files": [],
+ "references": [
+ {
+ "path": "./.nuxt/tsconfig.app.json"
+ },
+ {
+ "path": "./.nuxt/tsconfig.server.json"
+ },
+ {
+ "path": "./.nuxt/tsconfig.shared.json"
+ },
+ {
+ "path": "./.nuxt/tsconfig.node.json"
+ }
+ ]
+}
diff --git a/typesense-nuxt-search-bar/types/Book.ts b/typesense-nuxt-search-bar/types/Book.ts
new file mode 100644
index 0000000..a60803f
--- /dev/null
+++ b/typesense-nuxt-search-bar/types/Book.ts
@@ -0,0 +1,9 @@
+export type Book = {
+ id: string;
+ title: string;
+ authors: string[];
+ publication_year: number;
+ average_rating: number;
+ image_url: string;
+ ratings_count: number;
+};
diff --git a/typesense-nuxt-search-bar/utils/instantSearchAdapter.ts b/typesense-nuxt-search-bar/utils/instantSearchAdapter.ts
new file mode 100644
index 0000000..c7de503
--- /dev/null
+++ b/typesense-nuxt-search-bar/utils/instantSearchAdapter.ts
@@ -0,0 +1,27 @@
+import TypesenseInstantsearchAdapter from "typesense-instantsearch-adapter";
+
+export const createTypesenseAdapter = (config: {
+ apiKey: string;
+ host: string;
+ port: number;
+ protocol: string;
+}) => {
+ return new TypesenseInstantsearchAdapter({
+ server: {
+ apiKey: config.apiKey,
+ nodes: [
+ {
+ host: config.host,
+ port: config.port,
+ protocol: config.protocol,
+ },
+ ],
+ },
+ additionalSearchParameters: {
+ query_by: "title,authors",
+ query_by_weights: "4,2",
+ num_typos: 1,
+ sort_by: "ratings_count:desc",
+ },
+ });
+};