The Skeleton of Angular Project Structure
Starting a new Angular project can feel like moving into a massive, empty mansion. You know there are rooms for everything, but figuring out where to put the couch (and where the plumbing goes) takes a bit of planning.
Here is a comprehensive breakdown of a professional-grade Angular skeleton.
Before you build, you need the tools. Ensure you have Node.js (LTS version) installed.
Install Angular CLI globally:
npm install -g @angular/cli
Create a new project:
ng new my-awesome-app
Tip: Select SCSS for styling (it's more powerful) and enable Server-Side Rendering (SSR) if you care about SEO.
Navigate and Start:
cd my-awesome-app
ng serve --open
A "flat" structure works for tutorials, but for real-world apps, you want a modular approach.
src/app/core/: The "singleton" layer. Put services that should only have one instance here (e.g., AuthService, ApiService) and your global guards/interceptors.
src/app/shared/: The "reusable" layer. This contains components (buttons, spinners), pipes, and directives that are used across multiple feature modules.
src/app/features/ (or pages/): This is where the actual business logic lives. Each feature (e.g., dashboard, user-profile) gets its own folder with its own components and routing.
src/assets/: Static files like images, icons, and JSON translation files.
src/environments/: Configuration for different stages (e.g., environment.ts for dev and environment.prod.ts for production).
These files are the "brain" of your project.
| File | Purpose |
angular.json |
The CLI configuration. Defines build targets, asset paths, and global styles/scripts. |
package.json |
Manages dependencies and scripts (like npm start or npm run build). |
tsconfig.json |
TypeScript compiler settings. (Pro tip: use paths here to avoid ../../../../ import hell). |
app.config.ts |
(In modern Angular) The central place to provide global providers, routing, and animations. |
To move from a "default" app to a "production-ready" app, you should usually add these immediately:
Tailwind CSS or Angular Material: Don't reinvent the wheel for styling unless you have a very specific design system.
Prettier & ESLint: Keeps the code clean. Use ng add @angular-eslint/schematics.
State Management: If your app is huge, consider NgRx or Signals (the modern way) to manage data flow.
HTTP Interceptor: To automatically attach Auth tokens to every outgoing API request.
Every component you generate via ng generate component will follow this four-file pattern:
.ts: The logic (TypeScript).
.html: The structure.
.scss: The style (scoped specifically to this component).
.spec.ts: The unit tests (don't ignore these, your future self will thank you).
Note on Standalone Components: Modern Angular (v17+) defaults to "Standalone" components. This means you no longer need huge
app.module.tsfiles! Each component imports exactly what it needs.
Since you're ready to build out the meat of that skeleton, let's focus on the "Connective Tissue"—the Core Service and the HTTP Interceptor. These are the two most critical pieces for a functional, professional Angular app.
core/services/api.service.ts)Instead of injecting HttpClient into every single component, you should use a central API service. This allows you to change your base URL or handle global errors in one place.
TypeScript
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
@Injectable({ providedIn: 'root' })
export class ApiService {
private readonly baseUrl = environment.apiUrl;
constructor(private http: HttpClient) {}
get<T>(path: string, params: HttpParams = new HttpParams()): Observable<T> {
return this.http.get<T>(`${this.baseUrl}${path}`, { params });
}
post<T>(path: string, body: object = {}): Observable<T> {
return this.http.post<T>(`${this.baseUrl}${path}`, body);
}
}
core/interceptors/auth.interceptor.ts)In modern Angular (v17+), we use Functional Interceptors. This "skeleton" piece automatically injects your JWT token into every request and catches 401-Unauthorized errors.
TypeScript
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
// Clone the request to add the new header
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
authService.logout(); // Redirect to login if token expired
}
return throwError(() => error);
})
);
};
app.config.tsTo make these tools work, you have to "plug them in" at the root of your application.
TypeScript
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(
withInterceptors([authInterceptor]) // Plug in the interceptor here
)
]
};
When your skeleton is set up this way, your data follows a clean, predictable path:
Component calls a Feature Service.
Feature Service calls the Global ApiService.
Interceptor intercepts the request to add a Security Token.
Backend sends data back; Interceptor checks for errors.
Component receives clean data and displays it.
Don't forget to define your URLs in src/environments/environment.ts:
TypeScript
export const environment = {
production: false,
apiUrl: 'https://api.dev-example.com/v1'
};
Building a Shared UI Component is the best way to see the "Shared" folder philosophy in action. A Reusable Modal is the perfect example because it involves logic (opening/closing), styling, and content projection (letting other components put content inside it).
We’ll use Content Projection (<ng-content>) so this modal can act as a shell for any content you throw at it.
ng generate component shared/components/modal --standalone
modal.component.ts)We use @Input to control the title and visibility, and @Output to notify the parent when the modal should close.
TypeScript
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-modal',
standalone: true,
imports: [CommonModule],
templateUrl: './modal.component.html',
styleUrls: ['./modal.component.scss']
})
export class ModalComponent {
@Input() isOpen = false;
@Input() title = 'Default Title';
@Output() close = new EventEmitter<void>();
closeModal() {
this.close.emit();
}
}
modal.component.html)The <ng-content> tag is the "magic" spot where the parent’s HTML will be injected.
HTML
<div class="modal-backdrop" *ngIf="isOpen" (click)="closeModal()">
<div class="modal-container" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>{{ title }}</h3>
<button class="close-btn" (click)="closeModal()">×</button>
</div>
<div class="modal-body">
<ng-content></ng-content>
</div>
</div>
</div>
Now, let's see how a feature (like a User Profile) uses this shared component.
HTML
<button (click)="isModalOpen = true">Edit Profile</button>
<app-modal
[isOpen]="isModalOpen"
[title]="'Update User Info'"
(close)="isModalOpen = false">
<form>
<label>Username:</label>
<input type="text" placeholder="Enter new username">
<button type="submit">Save Changes</button>
</form>
</app-modal>
Your project structure should now look like this "Living Skeleton":
core/: Handles the "behind the scenes" (API, Auth, Interceptors).
shared/: Handles the "look and feel" (Modals, Buttons, Data-tables).
features/: Handles the "business" (Login, Dashboard, User Settings).
In modern Angular, we avoid complex RxJS for simple UI state and use Signals. Signals make data updates faster and more readable.
Since you’re already modernizing your skeleton, moving from traditional RxJS observables to Signals is like upgrading from a landline to a smartphone. Signals track exactly where data is used and only update those specific parts of the UI, making your app incredibly snappy.
In the old way, Angular had to check the entire component tree for changes (Change Detection). With Signals, Angular knows exactly which variable changed and which HTML element needs a refresh.
Let's create a UserService in your core/ folder that manages the current user's state globally.
TypeScript
import { Injectable, signal, computed } from '@angular/core';
export interface User {
id: number;
name: string;
role: 'admin' | 'user';
}
@Injectable({ providedIn: 'root' })
export class UserService {
// 1. The writable signal (private so only this service can change it)
private userSignal = signal<User | null>(null);
// 2. A read-only version for components to consume
readonly currentUser = this.userSignal.asReadonly();
// 3. A computed signal (automatically updates when userSignal changes)
readonly isAdmin = computed(() => this.userSignal()?.role === 'admin');
setUser(user: User) {
this.userSignal.set(user);
}
clearUser() {
this.userSignal.set(null);
}
}
In your component, you no longer need to subscribe or unsubscribe. You simply call the signal like a function in your HTML.
profile.component.ts)TypeScript
import { Component, inject } from '@angular/core';
import { UserService } from '../../core/services/user.service';
@Component({
selector: 'app-profile',
standalone: true,
templateUrl: './profile.component.html'
})
export class ProfileComponent {
// Inject the service
userService = inject(UserService);
updateName() {
// This will trigger an update everywhere the user is displayed
this.userService.setUser({ id: 1, name: 'Alex', role: 'admin' });
}
}
profile.component.html)Notice the parentheses ()—this is how you "read" a signal in the template.
HTML
<div *ngIf="userService.currentUser() as user; else guest">
<h1>Welcome, {{ user.name }}!</h1>
<p *ngIf="userService.isAdmin()">You have Admin permissions.</p>
</div>
<ng-template #guest>
<button (click)="updateName()">Log In as Admin</button>
</ng-template>
No Memory Leaks: Unlike Observables, you don't need to worry about .unsubscribe().
Cleaner Templates: No more | async pipes everywhere.
Performance: Angular doesn't have to guess what changed; the Signal tells it directly.
Installation: Standard Angular CLI.
Architecture: Core (logic), Shared (UI), Features (pages).
Communication: Centralized ApiService with an AuthInterceptor.
UI: Reusable components using ng-content.
State: Reactive data flow using Signals.
Now that we have our Signals managing user state, we need to lock the doors. Route Guards ensure that only authorized users can access specific parts of your application (like a /dashboard or /admin panel).
In modern Angular, we use Functional Guards, which are much leaner than the old class-based guards.
core/guards/auth.guard.ts)This guard will check our UserService signal. If a user isn't logged in, it kicks them back to the login page.
TypeScript
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { UserService } from '../services/user.service';
export const authGuard: CanActivateFn = (route, state) => {
const userService = inject(UserService);
const router = inject(Router);
// Check the signal value
if (userService.currentUser()) {
return true; // Door is open
}
// Door is locked, redirect to login
return router.parseUrl('/login');
};
Since we already created an isAdmin computed signal in the previous step, creating a role-based guard is incredibly simple:
TypeScript
export const adminGuard: CanActivateFn = () => {
const userService = inject(UserService);
const router = inject(Router);
return userService.isAdmin() ? true : router.parseUrl('/unauthorized');
};
app.routes.ts)You define which routes are protected directly in your routing configuration. This keeps your security logic centralized.
TypeScript
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
import { adminGuard } from './core/guards/admin.guard';
export const routes: Routes = [
{ path: 'login', component: () => import('./features/login/login.component').then(m => m.LoginComponent) },
// Protected Routes
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent)
},
{
path: 'admin',
canActivate: [authGuard, adminGuard], // Must be logged in AND an admin
loadComponent: () => import('./features/admin/admin.component').then(m => m.AdminComponent)
},
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' }
];
By using Functional Guards and Lazy Loading (loadComponent), your skeleton becomes highly optimized:
Security: Users can't "accidentally" stumble into private views.
Performance: The code for the /admin page isn't even downloaded by the browser unless the user passes the guards.
Readability: Your app.routes.ts acts as a clear map of your app's permissions.
You now have a production-ready skeleton:
Core: Global logic, API calls, and Security (Guards/Interceptors).
Shared: Reusable UI pieces (Modals/Signals).
Features: Isolated modules for your business logic.
Routing: Secure, lazy-loaded pathways.
A professional README.md is the "handshake" of your project. It helps other developers (and your future self) understand the architecture without digging through hundreds of files.
Here is a template tailored specifically to the Signals-based, Modular Skeleton we just built.
This project is a high-performance, scalable Angular application built with v17+ Standalone Components, Signals for state management, and a Modular Core/Shared/Feature architecture.
src/
└── app/
├── core/ # Singletons: Services, Guards, Interceptors
│ ├── guards/ # Route protection (Auth, Admin)
│ ├── interceptors/ # HTTP logic (Auth tokens, Error handling)
│ └── services/ # Global logic (ApiService, UserService)
├── shared/ # Reusable UI: Components, Pipes, Directives
│ └── components/ # Modals, Buttons, Data-tables
├── features/ # Business Logic: Lazy-loaded feature pages
│ ├── dashboard/
│ └── login/
└── app.config.ts # Global providers & configuration
Angular 17+: Utilizing Standalone Components (No NgModules).
State Management: Angular Signals for reactive, zone-less performance.
Styling: SCSS / [Tailwind CSS] for utility-first design.
Security: Functional Route Guards and JWT Interceptors.
Node.js (LTS)
Angular CLI: npm install -g @angular/cli
Clone the repo: git clone <repository-url>
Install dependencies: npm install
Start dev server: ng serve
Navigate to http://localhost:4200/
Always use lazy loading for new features. Generate components within the features/ folder: ng generate component features/my-new-feature
Use the UserService pattern. Avoid local state sprawl; if data is shared across components, use a Signal in a Core service.
When building a shared component, use Content Projection (<ng-content>) to ensure maximum reusability.
Strict Typing: Always define interfaces for API responses.
Clean Code: Run npm run lint before committing.
Testing: Write .spec.ts files for logic-heavy services.
Create a file named README.md in your project root.
Paste the content above.
Replace placeholders like <repository-url> with your actual data.
Since you're aiming for a production-ready setup, adding Unit Tests for your Signal-based services is the final piece of the puzzle. Testing Signals is slightly different from testing traditional variables because you want to ensure the "reactivity" (the automatic updates) is working correctly.
user.service.spec.ts)Testing a service with Signals is straightforward because you don't have to deal with complex asynchronous subscribe blocks in many cases.
TypeScript
import { TestBed } from '@angular/core/testing';
import { UserService, User } from './user.service';
describe('UserService', () => {
let service: UserService;
const mockUser: User = { id: 1, name: 'John Doe', role: 'admin' };
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserService);
});
it('should initialize with a null user', () => {
expect(service.currentUser()).toBeNull();
});
it('should update the user signal when setUser is called', () => {
service.setUser(mockUser);
expect(service.currentUser()).toEqual(mockUser);
});
it('should correctly compute isAdmin status', () => {
service.setUser(mockUser);
expect(service.isAdmin()).toBeTrue();
service.setUser({ id: 2, name: 'Jane', role: 'user' });
expect(service.isAdmin()).toBeFalse();
});
it('should clear the user on clearUser', () => {
service.setUser(mockUser);
service.clearUser();
expect(service.currentUser()).toBeNull();
});
});
When testing components that use your shared signals, you want to verify that the DOM updates when the signal changes.
TypeScript
it('should display the username when a user is logged in', () => {
const userService = TestBed.inject(UserService);
userService.setUser({ id: 1, name: 'Angular Expert', role: 'user' });
// Trigger change detection to update the view
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Welcome, Angular Expert');
});
To make this "Skeleton" truly complete, you should automate these tests. In your package.json, you already have scripts, but you can refine them for your CI/CD pipeline (like GitHub Actions or GitLab CI).
| Script | Purpose |
npm test |
Runs the test suite in "watch" mode (local dev). |
ng test --watch=false --browsers=ChromeHeadless |
Runs tests once for CI/CD pipelines. |
ng build --configuration production |
Compiles the app for the web server. |
At this point, you have successfully built:
A Modular Directory (Core, Shared, Features).
Global Plumbing (ApiService & Auth Interceptors).
State Management (Signals for fast, reactive data).
Security (Functional Route Guards).
Reusability (Shared Modals with Content Projection).
Documentation & Quality (README and Unit Tests).
To wrap this up, we’ll move from the code editor to the Deployment & Operations phase. A professional skeleton isn't truly "complete" until it can be built, containerized, and deployed reliably.
Dockerizing your Angular app ensures that it runs exactly the same way in production as it does on your machine. We use a multi-stage build: one stage to build the app and another to serve it using Nginx.
Create a Dockerfile in your root directory:
Dockerfile
# Stage 1: Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build --configuration=production
# Stage 2: Serve
FROM nginx:stable-alpine
COPY --from=build /app/dist/my-awesome-app/browser /usr/share/nginx/html
# Copy custom nginx config if needed (for handling SPA routing)
# COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Automating your "Skeleton" ensures that every time you push code, your tests run and your build doesn't break.
Create .github/workflows/main.yml:
YAML
name: Angular CI/CD
on:
push:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Run Unit Tests
run: npx ng test --watch=false --browsers=ChromeHeadless
- name: Build Project
run: npm run build
Before you start coding your business logic, verify your "Mansion" is ready:
[ ] Type Safety: Interfaces defined for all API models.
[ ] Environment Secrets: API keys moved to environment.ts (and .env for CI).
[ ] Error Handling: A global HttpInterceptor to catch and toast server errors.
[ ] Scannability: Folder structure follows the Core/Shared/Feature pattern.
[ ] Performance: Signals used for UI state; Lazy loading used for all routes.
You now have a blueprint that balances Modern Features (Signals, Standalone Components) with Industry Standards (Interceptors, Guards, Docker).
Project Core: Centralized API services and Auth logic.
Shared UI: Reusable components like Modals with content projection.
State Layer: High-performance Signals for global reactivity.
Security Layer: Functional Route Guards.
Quality Layer: Unit tests and automated CI/CD configurations.
You’re all set! Your skeleton is robust enough to handle everything from a small MVP to a massive enterprise application.
Since you’re ready to put the first "tenant" into this mansion, let’s build a User Dashboard Feature. This will pull together everything we’ve built: the ApiService, Signals, Shared Modals, and Route Guards.
We’ll create a standalone dashboard component. ng generate component features/dashboard --standalone
In the logic below, we’ll use our ApiService to fetch data and store it in a local Signal.
dashboard.component.ts
TypeScript
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ApiService } from '../../core/services/api.service';
import { ModalComponent } from '../../shared/components/modal/modal.component';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, ModalComponent], // Import our Shared Modal
templateUrl: './dashboard.component.html'
})
export class DashboardComponent implements OnInit {
private api = inject(ApiService);
// Local signals for UI state
stats = signal<any>(null);
isLoading = signal(false);
isDetailModalOpen = signal(false);
ngOnInit() {
this.loadStats();
}
loadStats() {
this.isLoading.set(true);
this.api.get<any>('/user/stats').subscribe({
next: (data) => {
this.stats.set(data);
this.isLoading.set(false);
},
error: () => this.isLoading.set(false)
});
}
}
Using our shared modal and the Signal's getter syntax () makes the template very readable.
dashboard.component.html
HTML
<div class="dashboard-container">
<h1>Your Overview</h1>
<div *ngIf="isLoading()">
<p>Crunching the numbers...</p>
</div>
<div *ng砌f="stats() as data" class="stats-grid">
<div class="card">
<h3>Total Projects</h3>
<p>{{ data.projectCount }}</p>
<button (click)="isDetailModalOpen.set(true)">View Details</button>
</div>
</div>
<app-modal
[isOpen]="isDetailModalOpen()"
[title]="'Project Deep Dive'"
(close)="isDetailModalOpen.set(false)">
<p>Detailed breakdown of your project performance goes here.</p>
<ul>
<li *ngFor="let item of stats()?.details">{{ item.name }}: {{ item.value }}</li>
</ul>
</app-modal>
</div>
Now, when this feature runs:
Router checks the authGuard.
Component initializes and triggers loadStats().
ApiService sends the request.
AuthInterceptor automatically attaches the JWT token.
Signal updates the UI instantly once the data arrives.
You have officially transitioned from a blank folder to a fully-architected Angular ecosystem. You have:
Infrastructure: Docker, CI/CD, and professional README.
Core Logic: Interceptors, Guards, and Global Services.
Shared Assets: Reusable UI components.
Modern State: Signal-driven data flow.
Since we’ve laid the foundation, here is the Final Handover Checklist to help you decide which path to take based on your project's specific needs:
If you are building a blog, a store, or a public-facing site:
Next Step: Ensure Angular Universal (SSR) is configured.
Action: Check your server.ts file and ensure your meta-tags are updated dynamically using the Meta and Title services.
If you have hundreds of forms, complex permissions, and massive data:
Next Step: Implement Reactive Forms with custom validators.
Action: Create a FormService in your shared folder to handle reusable validation error messages.
If you are building a chat app, a stock tracker, or a collaborative tool:
Next Step: Integrate WebSockets or Firebase.
Action: Add a SocketService to the core folder that converts stream events into Signals.
| Layer | Responsibility | Technology |
| Presentation | What the user sees and clicks | Standalone Components + Signals |
| Shared | Visual consistency | Reusable UI + Content Projection |
| Business | Logic and data transformation | Feature Services |
| Core | Security, Networking, State | Interceptors, Guards, Global Signals |
| Infrastructure | Deployment and Scalability | Docker + Nginx + CI/CD |
Pro-Tip: As your project grows, keep an eye on your bundle size. Use webpack-bundle-analyzer occasionally to make sure your "Shared" folder hasn't become a "Junk Drawer" that slows down your initial load.